Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 27 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ GitHub multi-author avatars (`bot & human`) come from commit trailers, not from

## Discord-originated pull requests (REQUIRED)

When Discord work produces commits (or is clearly intended to land):

0. **Always open a PR — do not wait for perfect green.** Create the PR as soon as there is something to review or track. If full lint / typecheck / focused tests / `vp check` are not finished yet, open it as a **draft**. Convert to ready for review only after those gates. A missing PR while work sits only on a remote branch is incomplete handoff.

When opening or updating a PR from a Discord thread:

1. **Discord footer (required in the PR description).** Append this exact footer form at the end of the PR body (use the **thread starter** when known, otherwise the current requester, and that thread’s real jump link):
Expand All @@ -168,15 +172,21 @@ Prefer the thread starter’s Discord id/display name from turn context. Do not

### Mandatory pre-push / PR handoff gate (no exceptions)

**Before every `git push`, `fork:stack update --push`, PR open, or “handoff / done” claim**, the agent
**must** run the local gates that mirror Fork CI’s **Check** job (format/lint/typecheck/desktop
build pieces you can run on the host), fix all failures, then push. Fork CI is a safety net, not
the first typechecker.
**Before every `git push`, `fork:stack update --push`, non-draft PR open, ready-for-review
conversion, or “handoff / done” claim**, the agent **must** run the local gates that mirror Fork
CI’s **Check** job (format/lint/typecheck/desktop build pieces you can run on the host), fix all
failures, then push. Fork CI is a safety net, not the first typechecker.

**Always open a PR for Discord/agent work that produces commits** (see _Discord-originated pull
requests_). **Draft PR exception:** you may open/update a **draft** PR earlier for tracking once
commits exist, co-author trailers are correct, and focused tests for the changed behavior have been
run — even if full monorepo typecheck / root `vp check` are still in progress. Do not claim the work
is ready or mark the PR non-draft until the full gate below passes.

Run from the repository root, in order:

1. **`vp check`** — exact formatter/linter gate used by Fork CI **Check**. A focused format/lint
while iterating is fine; it is **not** a substitute for this root command before push.
while iterating is fine; it is **not** a substitute for this root command before ready handoff.
2. **Full monorepo typecheck** (matches Fork CI):

```bash
Expand All @@ -185,8 +195,8 @@ Run from the repository root, in order:

Equivalent: `vp run typecheck` / root `pnpm` typecheck script that runs recursive package
typechecks. **Scoped** typecheck of only the package you edited is allowed **while iterating**,
but **before push you must run the full recursive typecheck**. Failures in packages you did not
touch still block push: your tip inherits the base; fix or land a fix on the tip so CI is green.
but **before ready handoff you must run the full recursive typecheck**. Failures in packages you
did not touch still block: your tip inherits the base; fix or land a fix on the tip so CI is green.

3. **Desktop Check pieces when the tip can break them** (Fork CI **Check** also runs these): after
desktop or preload-adjacent changes, run `vp run --cache build:desktop` and the preload verify
Expand All @@ -197,21 +207,24 @@ Run from the repository root, in order:
is what the package uses.
- Backend / contracts / runtime behavior changes **must** include and run focused tests for the
changed behavior.
5. **Do not push** if steps 1–2 fail, or if required steps 3–4 fail. Fix first.
5. **Do not push a ready (non-draft) handoff** if steps 1–2 fail, or if required steps 3–4 fail.
Fix first.

**Ordinary feature PRs (based on `fork/changes`):** full-workspace `vp run test` is optional unless
the user asks or the change clearly needs the whole suite. **Do not** skip steps 1–2 to save time.
the user asks or the change clearly needs the whole suite. **Do not** skip steps 1–2 to save time
on ready handoff.

**Explicitly forbidden before handoff:**
**Explicitly forbidden before ready handoff:**

- Pushing after only unit tests, only scoped package typecheck, or only a partial lint.
- Opening/updating a PR knowing typecheck or `vp check` was skipped or red.
- Ready/non-draft push after only unit tests, only scoped package typecheck, or only a partial lint.
- Marking a PR ready for review knowing typecheck or `vp check` was skipped or red.
- Treating “CI will catch it” as a substitute for local gates.
- Advancing a stack rewrite to the next layer while the current layer is red (see below).
- Leaving Discord/agent work with commits but **no** PR (use draft until gates finish).

While iterating mid-task (not yet pushing), keep feedback loops small: format/lint the files you
While iterating mid-task (not yet ready), keep feedback loops small: format/lint the files you
touch, typecheck the packages you edit, run the smallest relevant tests. **The bar rises to the
full pre-push gate the moment you push or hand off.**
full pre-push gate the moment you mark ready or claim done.**

### Per-layer stack CI (stop the line — no exceptions)

Expand Down
112 changes: 112 additions & 0 deletions apps/discord-bot/src/features/MentionRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ import {
mergeJiraIssueKeys,
} from "../presentation/jiraLinks.ts";
import { extractPullRequestUrlsFromDiscordMessage } from "../presentation/prLinks.ts";
import {
assignPullRequestAssignees,
formatAssignSlashReply,
resolveAssignGithubLogin,
} from "../presentation/prAssign.ts";
import {
idleMessageFields,
stripBotMention,
Expand Down Expand Up @@ -3291,6 +3296,113 @@ const make = (botConfig: DiscordBotConfig) =>
),
),
),

assign: Effect.gen(function* () {
const interaction = yield* Ix.Interaction;
const channelId = interaction.channel_id;
if (channelId === undefined || channelId.length === 0) {
return slashReply("Assign only works inside a linked Discord thread.", {
ephemeral: true,
});
}

const channel = yield* rest.getChannel(channelId);
if (!isThreadChannel(channel.type)) {
return slashReply("Assign is only supported inside a linked Discord thread.", {
ephemeral: true,
});
}

const existing = yield* links.getByDiscordThreadId(channelId);
if (existing === null) {
return slashReply(
"This Discord thread is not linked to a T3 thread, so there are no PRs to assign.",
{ ephemeral: true },
);
}

const githubOption = (
Option.getOrElse(HashMap.get(ix.optionsMap, "github"), () => "") ?? ""
).trim();
const requesterId = interaction.member?.user?.id ?? interaction.user?.id ?? null;
const resolved = resolveAssignGithubLogin({
githubOption: githubOption.length > 0 ? githubOption : undefined,
requesterDiscordId: requesterId,
resolveByDiscordId: (discordId) => identityMap.resolveByDiscordId(discordId),
});
if (!resolved.ok) {
return slashReply(resolved.message, { ephemeral: true });
}

const prUrls = existing.prUrls ?? [];
if (prUrls.length === 0) {
return slashReply(
`No linked pull requests on this thread yet. Open or post a PR first, then run \`/agent assign\`.`,
{ ephemeral: true },
);
}

// gh API can exceed Discord's ~3s window when multiple PRs are linked.
const applicationId = interaction.application_id;
const token = interaction.token;
const login = resolved.login;
yield* forkSlashBackground(
Effect.gen(function* () {
yield* Effect.sleep("250 millis");
// assignPullRequestAssignees is best-effort (per-URL results; does not throw).
const results = yield* Effect.promise(() =>
assignPullRequestAssignees({
prUrls,
login,
}),
);
const content = formatAssignSlashReply({ login, results });
yield* Effect.logInfo("Discord slash assign completed", {
discordThreadId: channelId,
t3ThreadId: existing.t3ThreadId,
login,
source: resolved.source,
assigned: results.filter((r) => r.status === "assigned").length,
errors: results.filter((r) => r.status === "error").length,
actorId: requesterId,
});
yield* rest
.updateOriginalWebhookMessage(applicationId, token, {
payload: { content },
})
.pipe(
Effect.catch((error) =>
Effect.logWarning("Failed to edit deferred assign response", {
channelId,
error: String(error),
}),
),
);
}).pipe(
Effect.catchCause((cause) =>
rest
.updateOriginalWebhookMessage(applicationId, token, {
payload: {
content: `Assign failed: ${formatAlertCause(cause, 300)}`,
},
})
.pipe(Effect.ignore),
),
),
);

// Public so the thread sees who assigned whom.
return slashDefer();
}).pipe(
Effect.catch((error: unknown) =>
Effect.succeed(
slashReply(
`Assign failed: ${error instanceof Error ? error.message : String(error)}`,
{ ephemeral: true },
),
),
),
),
});
},
);
Expand Down
2 changes: 2 additions & 0 deletions apps/discord-bot/src/identityMap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ describe("resolveParticipantIdentity + formatIdentityAttributionBlock", () => {
"Co-authored-by: Patrick Roza <12345+patroza@users.noreply.github.com>",
);
expect(block).toContain("do not invent emails");
expect(block).toContain("Always open a PR");
expect(block).toContain("draft PR");
});

it("dedupes identical trailers when starter is also requester", () => {
Expand Down
3 changes: 3 additions & 0 deletions apps/discord-bot/src/identityMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,9 @@ export function formatIdentityAttributionBlock(input: {
lines.push(
"5. When opening a PR: include the Discord description footer from AGENTS.md (opened by … in chat thread **Discord**). The bot may hard-append the footer later — still write it on create. GitHub multi-author avatars come from **commit** trailers, not PR body prose alone.",
);
lines.push(
"6. **Always open a PR** for this work once there are commits (or the change is clearly intended to land). Prefer a **draft PR** until full lint / typecheck / focused tests / `vp check` are done; do not hold the PR closed waiting for perfect green.",
);

if (trailers.length > 0) {
lines.push("");
Expand Down
1 change: 1 addition & 0 deletions apps/discord-bot/src/presentation/channelInfoPin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ describe("channel info pin helpers", () => {
expect(rendered).toContain("/omegent thread-talk action:on|off|status");
expect(rendered).toContain("/omegent link ref:<id|url>");
expect(rendered).toContain("/omegent refresh-indicators");
expect(rendered).toContain("/omegent assign [github:login]");
expect(rendered).toContain("@Omegent …");
expect(rendered).toContain("Same actions (fallback)");
expect(rendered).toContain("/omegent steernow");
Expand Down
1 change: 1 addition & 0 deletions apps/discord-bot/src/presentation/channelInfoPin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ function buildChannelInfoPinBody(input: {
"/omegent thread-talk action:on|off|status",
"/omegent link ref:<id|url>",
"/omegent refresh-indicators",
"/omegent assign [github:login] Assign linked PR(s) (default: you)",
"@Omegent … Same actions (fallback)",
" flags: --plan --local --base <b> --provider <id> --model <slug>",
" --steer (inject now) --queue (park; default mid-turn)",
Expand Down
142 changes: 142 additions & 0 deletions apps/discord-bot/src/presentation/prAssign.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, expect, it } from "vite-plus/test";

import {
assignPullRequestAssignees,
formatAssignSlashReply,
parseAssignGithubOption,
resolveAssignGithubLogin,
} from "./prAssign.ts";

describe("parseAssignGithubOption", () => {
it("treats empty, @me, me, self as self", () => {
expect(parseAssignGithubOption(undefined)).toEqual({ kind: "self" });
expect(parseAssignGithubOption(null)).toEqual({ kind: "self" });
expect(parseAssignGithubOption("")).toEqual({ kind: "self" });
expect(parseAssignGithubOption(" ")).toEqual({ kind: "self" });
expect(parseAssignGithubOption("@me")).toEqual({ kind: "self" });
expect(parseAssignGithubOption("ME")).toEqual({ kind: "self" });
expect(parseAssignGithubOption("self")).toEqual({ kind: "self" });
});

it("accepts logins with or without @", () => {
expect(parseAssignGithubOption("MindfulLearner")).toEqual({
kind: "login",
login: "MindfulLearner",
});
expect(parseAssignGithubOption("@MindfulLearner")).toEqual({
kind: "login",
login: "MindfulLearner",
});
});

it("rejects invalid logins", () => {
expect(parseAssignGithubOption("-bad")).toMatchObject({ kind: "invalid" });
expect(parseAssignGithubOption("has space")).toMatchObject({ kind: "invalid" });
});
});

describe("resolveAssignGithubLogin", () => {
const map = new Map<
string,
{ readonly github?: { readonly login?: string | undefined } | undefined }
>([
["111", { github: { login: "MindfulLearner" } }],
["222", {}],
]);

it("returns explicit login without consulting the map", () => {
expect(
resolveAssignGithubLogin({
githubOption: "@other-dev",
requesterDiscordId: "111",
resolveByDiscordId: (id) => map.get(id) ?? null,
}),
).toEqual({ ok: true, login: "other-dev", source: "explicit" });
});

it("resolves @me via identity map", () => {
expect(
resolveAssignGithubLogin({
githubOption: undefined,
requesterDiscordId: "111",
resolveByDiscordId: (id) => map.get(id) ?? null,
}),
).toEqual({ ok: true, login: "MindfulLearner", source: "self" });
});

it("fails when unmapped or missing github login", () => {
expect(
resolveAssignGithubLogin({
requesterDiscordId: "999",
resolveByDiscordId: (id) => map.get(id) ?? null,
}).ok,
).toBe(false);
expect(
resolveAssignGithubLogin({
requesterDiscordId: "222",
resolveByDiscordId: (id) => map.get(id) ?? null,
}).ok,
).toBe(false);
});
});

describe("assignPullRequestAssignees", () => {
it("posts assignees for each valid PR and reports errors", async () => {
const calls: string[][] = [];
const results = await assignPullRequestAssignees({
prUrls: [
"https://github.com/acme/app/pull/12",
"not-a-pr",
"https://github.com/acme/app/pull/13",
],
login: "MindfulLearner",
execFile: async (_file, args) => {
calls.push([...args]);
if (args.includes("repos/acme/app/issues/13/assignees")) {
throw new Error("HTTP 422: Validation Failed");
}
return { stdout: "", stderr: "" };
},
});

expect(results).toEqual([
{ url: "https://github.com/acme/app/pull/12", status: "assigned" },
{ url: "not-a-pr", status: "skipped", detail: "not a github pull request url" },
{
url: "https://github.com/acme/app/pull/13",
status: "error",
detail: "HTTP 422: Validation Failed",
},
]);
expect(calls).toHaveLength(2);
expect(calls[0]).toEqual([
"api",
"repos/acme/app/issues/12/assignees",
"-X",
"POST",
"-f",
"assignees[]=MindfulLearner",
]);
});
});

describe("formatAssignSlashReply", () => {
it("summarizes assigned and failed PRs", () => {
const text = formatAssignSlashReply({
login: "MindfulLearner",
results: [
{ url: "https://github.com/a/b/pull/1", status: "assigned" },
{ url: "https://github.com/a/b/pull/2", status: "error", detail: "forbidden" },
],
});
expect(text).toContain("@MindfulLearner");
expect(text).toContain("https://github.com/a/b/pull/1");
expect(text).toContain("forbidden");
});

it("handles empty PR list", () => {
expect(formatAssignSlashReply({ login: "x", results: [] })).toContain(
"No linked pull requests",
);
});
});
Loading