From f62633f601e18b80f9f8ede5337b5b84d7cbe80a Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:21:46 +0000 Subject: [PATCH] feat(discord-bot): /agent assign for linked PRs + always open draft PRs Add `/omegent assign` (alias `/agent assign`) to assign linked GitHub PRs on a Discord thread to the invoker (identity map) or an explicit GitHub login. Document that Discord agents must always open a PR (draft until full lint/typecheck/tests/vp check) in turn meta, AGENTS.md, and docs. Co-authored-by: Joshua Dimaunahan <170177550+MindfulLearner@users.noreply.github.com> Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- AGENTS.md | 41 ++-- .../discord-bot/src/features/MentionRouter.ts | 112 ++++++++++ apps/discord-bot/src/identityMap.test.ts | 2 + apps/discord-bot/src/identityMap.ts | 3 + .../src/presentation/channelInfoPin.test.ts | 1 + .../src/presentation/channelInfoPin.ts | 1 + .../src/presentation/prAssign.test.ts | 142 +++++++++++++ apps/discord-bot/src/presentation/prAssign.ts | 196 ++++++++++++++++++ .../src/presentation/slashCommands.test.ts | 7 + .../src/presentation/slashCommands.ts | 13 ++ .../src/presentation/threadContext.test.ts | 2 + .../src/presentation/threadContext.ts | 3 +- docs/integrations/discord-bot.md | 6 + 13 files changed, 514 insertions(+), 15 deletions(-) create mode 100644 apps/discord-bot/src/presentation/prAssign.test.ts create mode 100644 apps/discord-bot/src/presentation/prAssign.ts diff --git a/AGENTS.md b/AGENTS.md index 7328eea83b9..2ddb1bf6a2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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): @@ -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 @@ -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 @@ -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) diff --git a/apps/discord-bot/src/features/MentionRouter.ts b/apps/discord-bot/src/features/MentionRouter.ts index f04f056e742..c4a53ee687e 100644 --- a/apps/discord-bot/src/features/MentionRouter.ts +++ b/apps/discord-bot/src/features/MentionRouter.ts @@ -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, @@ -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 }, + ), + ), + ), + ), }); }, ); diff --git a/apps/discord-bot/src/identityMap.test.ts b/apps/discord-bot/src/identityMap.test.ts index d921c4605b8..9a6f3b4a7eb 100644 --- a/apps/discord-bot/src/identityMap.test.ts +++ b/apps/discord-bot/src/identityMap.test.ts @@ -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", () => { diff --git a/apps/discord-bot/src/identityMap.ts b/apps/discord-bot/src/identityMap.ts index 3255f2d22d0..957dbbadd0e 100644 --- a/apps/discord-bot/src/identityMap.ts +++ b/apps/discord-bot/src/identityMap.ts @@ -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(""); diff --git a/apps/discord-bot/src/presentation/channelInfoPin.test.ts b/apps/discord-bot/src/presentation/channelInfoPin.test.ts index 5596b8195eb..c75e67588ca 100644 --- a/apps/discord-bot/src/presentation/channelInfoPin.test.ts +++ b/apps/discord-bot/src/presentation/channelInfoPin.test.ts @@ -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:"); 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"); diff --git a/apps/discord-bot/src/presentation/channelInfoPin.ts b/apps/discord-bot/src/presentation/channelInfoPin.ts index 88f11b24a36..c05290ba1bf 100644 --- a/apps/discord-bot/src/presentation/channelInfoPin.ts +++ b/apps/discord-bot/src/presentation/channelInfoPin.ts @@ -137,6 +137,7 @@ function buildChannelInfoPinBody(input: { "/omegent thread-talk action:on|off|status", "/omegent link ref:", "/omegent refresh-indicators", + "/omegent assign [github:login] Assign linked PR(s) (default: you)", "@Omegent … Same actions (fallback)", " flags: --plan --local --base --provider --model ", " --steer (inject now) --queue (park; default mid-turn)", diff --git a/apps/discord-bot/src/presentation/prAssign.test.ts b/apps/discord-bot/src/presentation/prAssign.test.ts new file mode 100644 index 00000000000..a6eb4f47bd9 --- /dev/null +++ b/apps/discord-bot/src/presentation/prAssign.test.ts @@ -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", + ); + }); +}); diff --git a/apps/discord-bot/src/presentation/prAssign.ts b/apps/discord-bot/src/presentation/prAssign.ts new file mode 100644 index 00000000000..a1da6636928 --- /dev/null +++ b/apps/discord-bot/src/presentation/prAssign.ts @@ -0,0 +1,196 @@ +/** + * Assign linked GitHub PRs from Discord slash `/omegent assign` (alias `/agent assign`). + * + * - No `github` option → assign to the invoker's mapped GitHub login (`@me`). + * - `github: MindfulLearner` / `@MindfulLearner` → assign that login. + */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeUtil from "node:util"; + +import { gitCommandEnv } from "./githubLinks.ts"; +import { normalizePullRequestUrl, type NormalizedPullRequestLink } from "./prLinks.ts"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); + +type ExecFileResult = { + readonly stdout: string; + readonly stderr: string; +}; + +type ExecFileLike = ( + file: string, + args: ReadonlyArray, + options?: NodeChildProcess.ExecFileOptions, +) => Promise; + +/** Discord slash / mention placeholders that mean "the person who ran the command". */ +const SELF_ASSIGNEE_TOKENS = new Set(["", "@me", "me", "self"]); + +/** + * Normalize a GitHub login from slash input. + * Returns `null` for empty / @me / self (caller should resolve via identity map). + * Returns `{ ok: false }` for invalid logins. + */ +export function parseAssignGithubOption(raw: string | null | undefined): + | { readonly kind: "self" } + | { readonly kind: "login"; readonly login: string } + | { + readonly kind: "invalid"; + readonly detail: string; + } { + const trimmed = (raw ?? "").trim(); + if (SELF_ASSIGNEE_TOKENS.has(trimmed.toLowerCase())) { + return { kind: "self" }; + } + const withoutAt = trimmed.replace(/^@/u, "").trim(); + if (withoutAt.length === 0) { + return { kind: "self" }; + } + // GitHub login: alphanumeric / hyphen, max 39, cannot start/end with hyphen. + if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/u.test(withoutAt)) { + return { + kind: "invalid", + detail: `Invalid GitHub username \`${trimmed}\`. Use a login like \`MindfulLearner\`, or omit for yourself.`, + }; + } + return { kind: "login", login: withoutAt }; +} + +export type ResolveAssignLoginInput = { + readonly githubOption?: string | null | undefined; + readonly requesterDiscordId?: string | null | undefined; + readonly resolveByDiscordId: ( + discordId: string, + ) => { readonly github?: { readonly login?: string | undefined } | undefined } | null; +}; + +export type ResolveAssignLoginResult = + | { readonly ok: true; readonly login: string; readonly source: "self" | "explicit" } + | { readonly ok: false; readonly message: string }; + +/** + * Resolve who to assign: explicit GitHub login, or requester via identity map. + */ +export function resolveAssignGithubLogin(input: ResolveAssignLoginInput): ResolveAssignLoginResult { + const parsed = parseAssignGithubOption(input.githubOption); + if (parsed.kind === "invalid") { + return { ok: false, message: parsed.detail }; + } + if (parsed.kind === "login") { + return { ok: true, login: parsed.login, source: "explicit" }; + } + + const discordId = input.requesterDiscordId?.trim() ?? ""; + if (discordId.length === 0) { + return { + ok: false, + message: "Could not resolve your Discord user id. Pass `github:` explicitly.", + }; + } + const person = input.resolveByDiscordId(discordId); + const login = person?.github?.login?.trim(); + if (login === undefined || login.length === 0) { + return { + ok: false, + message: + "You are not in the Discord→GitHub identity map (or have no `githubLogin`). Pass `github:`, or ask an operator to map your Discord id.", + }; + } + return { ok: true, login, source: "self" }; +} + +export type AssignPullRequestResult = { + readonly url: string; + readonly status: "assigned" | "skipped" | "error"; + readonly detail?: string | undefined; +}; + +/** + * Assign `login` on each PR URL via GitHub Issues assignees API (works for PRs). + * Best-effort: failures are returned per-URL and never throw. + */ +export async function assignPullRequestAssignees(input: { + readonly prUrls: ReadonlyArray; + readonly login: string; + readonly execFile?: ExecFileLike; +}): Promise> { + const execImpl = input.execFile ?? execFile; + const login = input.login.trim().replace(/^@/u, ""); + const results: AssignPullRequestResult[] = []; + + for (const raw of input.prUrls) { + const normalized = normalizePullRequestUrl(raw); + if (normalized === null) { + results.push({ url: raw, status: "skipped", detail: "not a github pull request url" }); + continue; + } + + try { + await postPullRequestAssignee(normalized, login, execImpl); + results.push({ url: normalized.url, status: "assigned" }); + } catch (error) { + results.push({ + url: normalized.url, + status: "error", + detail: error instanceof Error ? error.message : String(error), + }); + } + } + + return results; +} + +async function postPullRequestAssignee( + pr: NormalizedPullRequestLink, + login: string, + execImpl: ExecFileLike, +): Promise { + // Issues assignees endpoint is the supported way to set PR assignees. + await execImpl( + "gh", + [ + "api", + `repos/${pr.owner}/${pr.repo}/issues/${pr.number}/assignees`, + "-X", + "POST", + "-f", + `assignees[]=${login}`, + ], + { env: gitCommandEnv(), maxBuffer: 2 * 1024 * 1024 }, + ); +} + +/** Format a short public slash reply summarizing assign results. */ +export function formatAssignSlashReply(input: { + readonly login: string; + readonly results: ReadonlyArray; +}): string { + const loginLabel = `@${input.login.replace(/^@/u, "")}`; + if (input.results.length === 0) { + return `No linked pull requests on this thread to assign to ${loginLabel}.`; + } + + const assigned = input.results.filter((r) => r.status === "assigned"); + const errors = input.results.filter((r) => r.status === "error"); + const skipped = input.results.filter((r) => r.status === "skipped"); + + const lines: string[] = []; + if (assigned.length > 0) { + lines.push( + `Assigned ${loginLabel} on ${assigned.length} PR(s):`, + ...assigned.map((r) => `• ${r.url}`), + ); + } + if (errors.length > 0) { + lines.push( + `Failed ${errors.length} PR(s):`, + ...errors.map((r) => `• ${r.url}${r.detail !== undefined ? ` — ${r.detail}` : ""}`), + ); + } + if (skipped.length > 0 && assigned.length === 0 && errors.length === 0) { + lines.push(`Nothing assignable (${skipped.length} skipped).`); + } + + return lines.join("\n"); +} diff --git a/apps/discord-bot/src/presentation/slashCommands.test.ts b/apps/discord-bot/src/presentation/slashCommands.test.ts index 320a8df7d47..d91f254c722 100644 --- a/apps/discord-bot/src/presentation/slashCommands.test.ts +++ b/apps/discord-bot/src/presentation/slashCommands.test.ts @@ -28,9 +28,16 @@ describe("Omegent slash command definition", () => { "thread-talk", "link", "refresh-indicators", + "assign", ]); }); + it("registers optional github option on assign", () => { + const assign = OMEGENT_SLASH_COMMAND.options.find((option) => option.name === "assign"); + expect(assign?.options?.[0]?.name).toBe("github"); + expect(assign?.options?.[0]?.required).toBe(false); + }); + it("uses Discord subcommand option types", () => { for (const option of OMEGENT_SLASH_COMMAND.options) { expect(option.type).toBe(Discord.ApplicationCommandOptionType.SUB_COMMAND); diff --git a/apps/discord-bot/src/presentation/slashCommands.ts b/apps/discord-bot/src/presentation/slashCommands.ts index 03b42354599..ee2145fb174 100644 --- a/apps/discord-bot/src/presentation/slashCommands.ts +++ b/apps/discord-bot/src/presentation/slashCommands.ts @@ -185,6 +185,19 @@ export const OMEGENT_SLASH_COMMAND = { name: "refresh-indicators", description: "Refresh Discord thread title badges (PR/VCS indicators)", }, + { + type: Discord.ApplicationCommandOptionType.SUB_COMMAND, + name: "assign", + description: "Assign linked PR(s) on this thread (default: you via identity map)", + options: [ + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "github", + description: "GitHub login (default: you / @me from the identity map)", + required: false, + }, + ], + }, ], } as const; diff --git a/apps/discord-bot/src/presentation/threadContext.test.ts b/apps/discord-bot/src/presentation/threadContext.test.ts index 45c58fa8506..28c34c6a400 100644 --- a/apps/discord-bot/src/presentation/threadContext.test.ts +++ b/apps/discord-bot/src/presentation/threadContext.test.ts @@ -148,6 +148,8 @@ describe("buildDiscordTurnPrompt", () => { expect(prompt).toContain("originated from a Discord thread"); expect(prompt).toContain("posted back into the same Discord thread"); + expect(prompt).toContain("Always open a GitHub PR"); + expect(prompt).toContain("draft PR"); expect(prompt).toContain('"you"'); expect(prompt).toContain('"id": "user-1"'); expect(prompt).toContain('"username": "example-user"'); diff --git a/apps/discord-bot/src/presentation/threadContext.ts b/apps/discord-bot/src/presentation/threadContext.ts index 23d22205a99..fa681a31af6 100644 --- a/apps/discord-bot/src/presentation/threadContext.ts +++ b/apps/discord-bot/src/presentation/threadContext.ts @@ -82,7 +82,8 @@ const DISCORD_CONVERSATION_CONTEXT = `## Discord conversation context - This turn originated from a Discord thread. You are the Discord bot speaking directly to the people in that thread. - Your final answer will be posted back into the same Discord thread and may be read by multiple participants. - When the requester says "you" or otherwise addresses the assistant, interpret that as referring to you in your role as the Discord bot unless they clearly identify someone else. -- Treat the current requester as distinct from the thread starter and from other participants. Do not attribute another participant's statements or identity to them.`; +- Treat the current requester as distinct from the thread starter and from other participants. Do not attribute another participant's statements or identity to them. +- **Always open a GitHub PR** for Discord work that produces commits (or is clearly intended to land). Do not wait until "everything is perfect." Use a **draft PR** when full lint / typecheck / tests / \`vp check\` are not finished yet; mark ready only after those gates.`; function formatRequesterMetadata(message: DiscordMessageLike | undefined): string { return JSON.stringify( diff --git a/docs/integrations/discord-bot.md b/docs/integrations/discord-bot.md index 1331513d3d9..f660031fb82 100644 --- a/docs/integrations/discord-bot.md +++ b/docs/integrations/discord-bot.md @@ -187,6 +187,12 @@ Guild-scoped `/t3` commands (fast to register for a single-server bot). Mentions | `/t3 thread-talk action:on\|off\|status` | Same as `@bot thread-talk …` | Public for on/off; ephemeral for status | | `/t3 link ref:` | Same as `@bot link …` | Public | | `/t3 refresh-indicators` | Force-refresh Discord thread title badges (PR/VCS: ▫️/🔀/✔️/…); also `@bot refresh-indicators` | Ephemeral (title change is visible on the thread) | +| `/omegent assign` / `/agent assign` | Assign linked PR(s) on this thread to **you** (identity map). Optional `github:` | **Public** summary after deferred gh call | +| `/omegent assign github:login` | Same, but assign the given GitHub username (with or without `@`) | **Public** summary after deferred gh call | + +### Agent PR policy (Discord turns) + +Every Discord turn injects conversation meta that requires agents to **always open a GitHub PR** for work that produces commits (or is clearly intended to land). Draft PRs are preferred until full lint / typecheck / tests / `vp check` finish; convert to ready only after those gates. See `AGENTS.md` → _Discord-originated pull requests_ and the identity-map attribution block (rule 6). **Visibility policy (neutral default):** shared-state mutations and agent work get **public** acks so the channel remains auditable. Personal/read-only signals (`help`, `thread-talk status`, benign “nothing to stop”) stay **ephemeral**. No role gates yet — any member in a project channel may use these.