diff --git a/docs/code/configuration.md b/docs/code/configuration.md index 8053730b..6e460ee0 100644 --- a/docs/code/configuration.md +++ b/docs/code/configuration.md @@ -106,6 +106,18 @@ Set both when you run mention-driven automation and also use GitHub Issues as a Do not set `GITHUB_APP_ID` without `GITHUB_APP_PRIVATE_KEY_PATH` or `GITHUB_APP_PRIVATE_KEY_BASE64`. The ID alone is ignored for auth, but the worker treats it as "GitHub credentials present." +### Bot mention aliases + +Mention matching resolves the bot login from your configured GitHub App. When a relay-managed worker should also react to the DevIntern AI App's identity — whose private key stays on DevIntern infrastructure and is never available locally — add its login as an alias: + +```bash +GITHUB_BOT_ALIASES=devintern-ai +``` + +The value is a comma-separated list of logins (with or without the `[bot]` suffix). Aliases count everywhere mentions are matched: commented reviews, inline comment scopes, and the `@mention` sweep. A worker connected to the relay (`devintern worker connect`) injects `devintern-ai` automatically; set the variable explicitly when running a custom App alongside it or without the relay. + +When a run is triggered by a `devintern-ai` mention, a relay-connected worker also has the relay mark any comments it could not react to locally with a šŸŽ‰ under the DevIntern AI identity, so the addressed-marker exists even when the local credentials cannot react. Failures here are logged and non-fatal; the local reaction remains the primary marker. + ### GitHub Personal Access Token For personal / interactive CLI use, and for `TASK_TRACKER=github`: @@ -155,10 +167,11 @@ Both the ID and a private key are required. 2. Set repository permissions: - **Contents:** Read and write - **Pull requests:** Read and write + - **Issues:** Read and write 3. Generate and save a private key 4. Install the App on your repositories -> These permissions cover task implementation and PR creation. If you also run the webhook server or mention sweep to auto-address PR feedback, that App needs additional **Pull request review comments** and **Issue comments** permissions plus event subscriptions; see [GitHub Integration](./github-integration.md#update-app-permissions). +> These permissions cover task implementation, PR creation, and the šŸŽ‰ reaction that marks review feedback as addressed. If reactions start failing with a permissions error after a settings change, re-approve the installation — already-issued credentials keep working for up to an hour, and unmarked feedback is re-processed on later runs. If you also run the webhook server or mention sweep to auto-address PR feedback, that App needs additional **Pull request review comments** and **Issue comments** permissions plus event subscriptions; see [GitHub Integration](./github-integration.md#update-app-permissions). For CI/CD environments, you can use a base64-encoded key: diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index 56f7fe12..957867cb 100755 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -909,7 +909,13 @@ if (process.argv[2] === "init") { // Import and run address-review const { addressReview } = await import("./lib/address-review"); try { - await addressReview(prUrl, { noPush, noReply, verbose }); + const result = await addressReview(prUrl, { noPush, noReply, verbose }); + // Workers read this to follow up (e.g. relay-initiated reactions). + const resultFd = Number(process.env.DEVINTERN_RESULT_FD); + if (Number.isInteger(resultFd) && resultFd >= 3) { + const { writeSync } = await import("fs"); + writeSync(resultFd, `${JSON.stringify(result)}\n`); + } } catch (error) { // Close any run record addressReview opened before it failed (no-op // when none is active — addressReview also ends runs it completes). diff --git a/packages/code/src/lib/address-review.ts b/packages/code/src/lib/address-review.ts index e7972c88..10be1cb9 100644 --- a/packages/code/src/lib/address-review.ts +++ b/packages/code/src/lib/address-review.ts @@ -21,6 +21,8 @@ import { beginRun, endRun, recordRunStage } from "./run-recorder"; import { formatReviewPrompt } from "./review-formatter"; import { GIT_CLEAN_ARGS, Utils } from "./utils"; import { isCommitAlreadyComplete, runAgentHarnessToFixGitHook } from "./git-hook-fixer"; +import { botMentionCandidates, mentionsAnyBot, mentionsBot } from "./mention-sweep-acquirer"; +import { RELAY_BOT_LOGIN } from "./relay-connect"; import type { ProcessedReviewComment, ProcessedReviewFeedback, @@ -33,6 +35,18 @@ export interface AddressReviewOptions { verbose?: boolean; } +/** Outcome of one address-review invocation, consumed by the worker. */ +export interface AddressReviewResult { + /** + * The run was triggered by a mention of the relay App identity + * (`devintern-ai`) rather than a locally-resolved bot login — the worker + * can then ask the relay to mark unmarked comments as that identity. + */ + aliasMentioned: boolean; + /** Comments that were in scope but not marked addressed locally. */ + unmarkedComments: Array<{ id: number; target: "review" | "conversation" }>; +} + interface ParsedPRUrl { owner: string; repo: string; @@ -67,43 +81,69 @@ function parsePRUrl(url: string): ParsedPRUrl { } /** - * Get the latest review with `changes_requested` state. + * Metadata of the review a run will act on. + */ +interface FeedbackReview { + reviewId: number; + reviewer: string; + body: string | null; + submittedAt: string; + /** GitHub REST review state (`CHANGES_REQUESTED` or `COMMENTED`). */ + state: string; +} + +/** + * Get the review a run should act on: the latest `changes_requested` review, + * or — when no `changes_requested` reviews exist — the latest `commented` + * review. A `commented` pick is only acted on when the bot is mentioned + * (the gate runs in {@link addressReview} once the comments are fetched); + * `changes_requested` reviews are always addressed. * * @param client - GitHub reviews API client * @param owner - Repository owner * @param repo - Repository name * @param prNumber - Pull request number - * @returns Latest changes-requested review metadata, or `null` if none exist + * @returns Review metadata, or `null` if none exist */ -async function getLatestChangesRequestedReview( +async function getLatestFeedbackReview( client: GitHubReviewsClient, owner: string, repo: string, prNumber: number, -): Promise<{ - reviewId: number; - reviewer: string; - body: string | null; - submittedAt: string; -} | null> { +): Promise { // Fetch all reviews for the PR using the client const reviews = await client.getReviews(owner, repo, prNumber); - // Find the latest "changes_requested" review + const byNewest = (a: { submitted_at: string }, b: { submitted_at: string }) => + new Date(b.submitted_at).getTime() - new Date(a.submitted_at).getTime(); + const changesRequestedReviews = reviews .filter((r) => r.state === "CHANGES_REQUESTED") - .sort((a, b) => new Date(b.submitted_at).getTime() - new Date(a.submitted_at).getTime()); + .sort(byNewest); + + if (changesRequestedReviews.length > 0) { + const latest = changesRequestedReviews[0]; + return { + reviewId: latest.id, + reviewer: latest.user.login, + body: latest.body, + submittedAt: latest.submitted_at, + state: latest.state, + }; + } - if (changesRequestedReviews.length === 0) { + const commentedReviews = reviews.filter((r) => r.state === "COMMENTED").sort(byNewest); + if (commentedReviews.length === 0) { return null; } - const latest = changesRequestedReviews[0]; + const latest = commentedReviews[0]; return { reviewId: latest.id, reviewer: latest.user.login, body: latest.body, submittedAt: latest.submitted_at, + state: latest.state, }; } @@ -244,6 +284,8 @@ export async function runAgent( * @param prNumber - Pull request number (unused; kept for API symmetry) * @param comments - Top-level and reply review comments to mark * @param conversationComments - Issue/conversation tab comments to mark + * @returns The in-scope comments that are NOT marked (skipped replies, + * failed reactions) — relay-reaction candidates for the worker */ async function markCommentsAddressed( client: GitHubReviewsClient, @@ -252,20 +294,32 @@ async function markCommentsAddressed( prNumber: number, comments: ProcessedReviewComment[], conversationComments: ProcessedConversationComment[], -): Promise { +): Promise { if (comments.length === 0 && conversationComments.length === 0) { - return; + return []; } let successCount = 0; + const unmarked: AddressReviewResult["unmarkedComments"] = []; + + /** Explain the classic App-permission 403 so the fix is obvious in the log. */ + const reactionFailureHint = (message: string): string => + message.includes("not accessible by integration") + ? `${message}\n` + + " The GitHub App installation lacks the Reactions permission — grant it " + + '"Reactions: Read & write" (App settings → Permissions), or react with šŸŽ‰ manually ' + + "to mark the comment addressed. Until marked, the comment is re-processed on later runs." + : message; // Add šŸŽ‰ (hooray) reaction to each review comment if (comments.length > 0) { console.log(` Marking ${comments.length} review comment(s) as addressed...`); for (const comment of comments) { - // Skip reply comments (only mark top-level comments) + // Skip reply comments (only mark top-level comments); they still count + // as unmarked for the relay so the dedupe marker covers them too. if (comment.isReply) { + unmarked.push({ id: comment.id, target: "review" }); continue; } @@ -273,8 +327,9 @@ async function markCommentsAddressed( await client.addReactionToComment(owner, repo, comment.id, "hooray"); successCount++; } catch (error) { + unmarked.push({ id: comment.id, target: "review" }); console.warn( - ` āš ļø Failed to add reaction to review comment ${comment.id}: ${(error as Error).message}`, + ` āš ļø Failed to add reaction to review comment ${comment.id}: ${reactionFailureHint((error as Error).message)}`, ); } } @@ -291,8 +346,9 @@ async function markCommentsAddressed( await client.addReactionToIssueComment(owner, repo, comment.id, "hooray"); successCount++; } catch (error) { + unmarked.push({ id: comment.id, target: "conversation" }); console.warn( - ` āš ļø Failed to add reaction to conversation comment ${comment.id}: ${(error as Error).message}`, + ` āš ļø Failed to add reaction to conversation comment ${comment.id}: ${reactionFailureHint((error as Error).message)}`, ); } } @@ -301,6 +357,7 @@ async function markCommentsAddressed( if (successCount > 0) { console.log(`āœ… Marked ${successCount} comment(s) as addressed with šŸŽ‰ reaction`); } + return unmarked; } /** @@ -313,8 +370,9 @@ async function markCommentsAddressed( export async function addressReview( prUrl: string, options: AddressReviewOptions = {}, -): Promise { +): Promise { const { noPush = false, noReply = false, verbose = false } = options; + const result: AddressReviewResult = { aliasMentioned: false, unmarkedComments: [] }; console.log("šŸ” Parsing PR URL..."); const { owner, repo, prNumber } = parsePRUrl(prUrl); @@ -340,8 +398,10 @@ export async function addressReview( } } - // Initialize GitHub client - const githubClient = new GitHubReviewsClient(); + // Initialize GitHub client. App auth takes precedence when configured so + // the bot identity resolves (`slug[bot]`) for @mention matching — a human + // GITHUB_TOKEN alone can never satisfy the commented-review mention gate. + const githubClient = new GitHubReviewsClient({ preferAppAuth: true }); // Get PR details console.log("\nšŸ“‹ Fetching PR details..."); @@ -354,21 +414,30 @@ export async function addressReview( throw new Error(`PR is ${pr.state}, not open. Cannot address review.`); } - // Get latest changes_requested review - console.log("\nšŸ”Ž Looking for changes_requested review..."); - const review = await getLatestChangesRequestedReview(githubClient, owner, repo, prNumber); + // Get latest actionable review (changes_requested, or commented when no + // changes_requested reviews exist — the commented pick is mention-gated + // after the comments below are fetched). + console.log("\nšŸ”Ž Looking for actionable review feedback..."); + const review = await getLatestFeedbackReview(githubClient, owner, repo, prNumber); if (!review) { - console.log("āœ… No pending changes_requested reviews found."); - return; + console.log("āœ… No pending changes_requested or commented reviews found."); + return result; } - console.log(` Found review from @${review.reviewer}`); + console.log(` Found ${review.state.toLowerCase()} review from @${review.reviewer}`); // Fetch ALL review comments for the PR (not just from this review) console.log("\nšŸ“„ Fetching review comments..."); const rawComments = await githubClient.getPullRequestReviewComments(owner, repo, prNumber); + // Resolve the bot identity once: it decides whether a commented review run + // triggers at all and whether a stray inline comment is an explicit ask. + // Aliases (GITHUB_BOT_ALIASES) extend the resolvable identity — e.g. the + // relay App's login, whose private key is not available locally. + const botName = await githubClient.getBotUsername(owner, repo); + const botNames = botMentionCandidates(botName); + // Check which comments already have a "hooray" reaction (marked as addressed) const addressedCommentIds = new Set(); @@ -389,8 +458,32 @@ export async function addressReview( } } - const processedComments: ProcessedReviewComment[] = rawComments - .filter((c) => !addressedCommentIds.has(c.id)) // Filter out already addressed + // Scope comments to this run: the chosen review's own threads plus explicit + // @mentions of the bot. Feedback that was never asked for (a stray comment + // from another review) stays unactioned until its author mentions the bot + // or submits their own actionable review. + const reviewThreadRootIds = new Set( + rawComments.filter((c) => c.pull_request_review_id === review.reviewId).map((c) => c.id), + ); + const rootIdOf = (comment: (typeof rawComments)[number]): number | undefined => { + let current: (typeof rawComments)[number] | undefined = comment; + const visited = new Set(); + while (current?.in_reply_to_id !== undefined && !visited.has(current.id)) { + visited.add(current.id); + current = rawComments.find((c) => c.id === current?.in_reply_to_id); + } + return current?.id; + }; + + const unaddressedComments = rawComments.filter((c) => !addressedCommentIds.has(c.id)); + const processedComments: ProcessedReviewComment[] = unaddressedComments + .filter((c) => { + const rootId = rootIdOf(c); + if (rootId !== undefined && reviewThreadRootIds.has(rootId)) { + return true; + } + return mentionsAnyBot(c.body, botNames); + }) .map((c) => ({ id: c.id, path: c.path, @@ -403,12 +496,19 @@ export async function addressReview( })); const totalComments = rawComments.length; - const alreadyAddressed = totalComments - processedComments.length; + const alreadyAddressed = totalComments - unaddressedComments.length; + const outOfScope = unaddressedComments.length - processedComments.length; console.log(` Found ${totalComments} comment(s)`); if (alreadyAddressed > 0) { console.log(` ${alreadyAddressed} already addressed (skipping)`); } + if (outOfScope > 0) { + console.log( + ` ${outOfScope} unaddressed but out of scope for this run ` + + "(different review thread and no bot @mention — not actioned)", + ); + } console.log(` ${processedComments.length} remaining to address`); // Fetch conversation comments (issue comments) @@ -457,11 +557,57 @@ export async function addressReview( } console.log(` ${processedConversationComments.length} remaining to address`); + // A commented review is informational by nature: only act on it when a bot + // identity is explicitly mentioned — in the review body itself or in one of + // the comments beneath it. changes_requested reviews are always addressed. + // Fails closed when no bot identity is configured at all. + if (review.state === "COMMENTED") { + const mentionSources = [ + review.body, + ...processedComments.map((c) => c.body), + ...processedConversationComments.map((c) => c.body), + ]; + const matchedBot = botNames.find((name) => + mentionSources.some((body) => mentionsBot(body, name)), + ); + // A mention of the relay identity (not resolvable locally) lets the + // worker ask the relay to mark comments as that identity afterwards. + result.aliasMentioned = + matchedBot !== undefined && + matchedBot !== botName && + matchedBot.replace(/\[bot\]$/i, "") === RELAY_BOT_LOGIN; + if (!matchedBot) { + if (botNames.length === 0) { + console.log( + "\nā­ļø Latest review is commented, but no bot identity is configured to verify @mentions — skipping.", + ); + console.log( + " Configure a GitHub App or set GITHUB_BOT_ALIASES (e.g. the relay App's login).", + ); + } else { + const names = botNames.map((name) => `@${name}`).join(" or "); + console.log( + `\nā­ļø Latest review is commented and nothing in it mentions ${names} — skipping.`, + ); + } + console.log( + " Commented reviews are addressed only when they mention the bot; changes_requested reviews are always addressed.", + ); + console.log(` View PR: ${prUrl}`); + return result; + } + if (verbose && botNames.length > 0) { + console.log( + ` šŸ’¬ Bot mention detected (${botNames.map((name) => `@${name}`).join(", ")}); addressing.`, + ); + } + } + // If no comments remaining (neither review nor conversation), we're done if (processedComments.length === 0 && processedConversationComments.length === 0) { console.log("\nāœ… All review and conversation comments have been addressed already."); console.log(` View PR: ${prUrl}`); - return; + return result; } // Build feedback object @@ -471,7 +617,7 @@ export async function addressReview( repository: `${owner}/${repo}`, branch: pr.head.ref, reviewer: review.reviewer, - reviewState: "changes_requested", + reviewState: review.state.toLowerCase() as ProcessedReviewFeedback["reviewState"], reviewBody: review.body, comments: processedComments, conversationComments: @@ -589,7 +735,7 @@ export async function addressReview( if (!hasUncommitted && !hasUnpushed) { console.log("\nāš ļø No changes were made by @devintern/code"); console.log(` View PR: ${prUrl}`); - return; + return result; } // Get hook retries configuration @@ -766,7 +912,7 @@ export async function addressReview( if (!noReply && !noPush) { console.log("\nšŸ’¬ Marking comments as addressed..."); - await markCommentsAddressed( + result.unmarkedComments = await markCommentsAddressed( githubClient, owner, repo, @@ -781,6 +927,7 @@ export async function addressReview( console.log(`\nāœ… Successfully addressed review for PR #${prNumber}`); console.log(` View PR: ${prUrl}`); endRun("succeeded"); + return result; } catch (error) { endRun("failed", (error as Error).message); throw error; diff --git a/packages/code/src/lib/mention-sweep-acquirer.ts b/packages/code/src/lib/mention-sweep-acquirer.ts index 7f8712bf..348bb5bd 100644 --- a/packages/code/src/lib/mention-sweep-acquirer.ts +++ b/packages/code/src/lib/mention-sweep-acquirer.ts @@ -92,6 +92,38 @@ export function mentionsBot(body: string | null, botName: string): boolean { ); } +/** + * Extra bot logins that should count as @mentions beyond the resolved GitHub + * identity, from `GITHUB_BOT_ALIASES` (comma-separated, with or without the + * `[bot]` suffix). The main use case is the relay: relay-managed PRs are + * associated with the DevIntern AI App identity, whose private key never + * leaves DevIntern infrastructure — so the local worker cannot resolve it via + * App auth and must be told the login instead. + */ +export function botMentionAliases(): string[] { + return (process.env.GITHUB_BOT_ALIASES ?? "") + .split(",") + .map((alias) => alias.trim()) + .filter(Boolean); +} + +/** + * All bot logins a mention gate should match: the resolved identity (App auth + * or a Bot-type token) plus configured aliases, deduped order-stable. + */ +export function botMentionCandidates(resolvedBotName: string | null): string[] { + return [ + ...new Set([resolvedBotName, ...botMentionAliases()].filter((n): n is string => Boolean(n))), + ]; +} + +/** + * Whether a comment body mentions any of the candidate bot logins. + */ +export function mentionsAnyBot(body: string | null, botNames: string[]): boolean { + return botNames.some((name) => mentionsBot(body, name)); +} + /** * Sweeps the repo for bot mentions on any PR. */ @@ -132,20 +164,20 @@ export class MentionSweepAcquirer implements Acquirer { this.busy = true; try { - const botName = await this.resolveBotName(); - if (!botName) { + const botNames = botMentionCandidates(await this.resolveBotName()); + if (botNames.length === 0) { return; } await this.sweepFeed( `github:mention:issuecomments:${this.options.repo}`, (since) => this.options.github.fetchIssueCommentsSince(since), - botName, + botNames, ); await this.sweepFeed( `github:mention:prcomments:${this.options.repo}`, (since) => this.options.github.fetchReviewCommentsSince(since), - botName, + botNames, ); } catch (error) { console.warn(`āš ļø [${this.name}] sweep failed: ${(error as Error).message}`); @@ -154,15 +186,15 @@ export class MentionSweepAcquirer implements Acquirer { } } - /** Resolve and cache the bot login; warn once when unavailable. */ + /** Resolve and cache the bot login; warn once when no identity is usable. */ private async resolveBotName(): Promise { if (this.botName === undefined) { this.botName = await this.options.github.getBotUsername(); - if (!this.botName && !this.warnedNoBot) { + if (!this.botName && botMentionAliases().length === 0 && !this.warnedNoBot) { this.warnedNoBot = true; console.warn( - `āš ļø [${this.name}] could not resolve a bot username (GitHub App auth required); ` + - `mention sweeping is disabled`, + `āš ļø [${this.name}] could not resolve a bot username (GitHub App auth required, ` + + `or set GITHUB_BOT_ALIASES); mention sweeping is disabled`, ); } } @@ -173,7 +205,7 @@ export class MentionSweepAcquirer implements Acquirer { private async sweepFeed( cursorSource: string, fetchSince: (sinceIso: string) => Promise, - botName: string, + botNames: string[], ): Promise { const { workerState, queue, github, handleMention, verbose } = this.options; @@ -191,7 +223,11 @@ export class MentionSweepAcquirer implements Acquirer { if (comment.created_at > maxCreatedAt) { maxCreatedAt = comment.created_at; } - if (comment.user.type === "Bot" || !mentionsBot(comment.body, botName)) { + if (comment.user.type === "Bot") { + continue; + } + const matchedBot = botNames.find((name) => mentionsBot(comment.body, name)); + if (!matchedBot) { continue; } @@ -236,7 +272,7 @@ export class MentionSweepAcquirer implements Acquirer { } console.log( - `\nšŸ“Œ [${this.name}] @${botName} mentioned on PR #${prNumber} by @${comment.user.login}`, + `\nšŸ“Œ [${this.name}] @${matchedBot} mentioned on PR #${prNumber} by @${comment.user.login}`, ); await handleMention(comment, prNumber); } catch (error) { diff --git a/packages/code/src/lib/relay-acquirer.ts b/packages/code/src/lib/relay-acquirer.ts index 7acbe0fd..9c68bf2c 100644 --- a/packages/code/src/lib/relay-acquirer.ts +++ b/packages/code/src/lib/relay-acquirer.ts @@ -32,8 +32,12 @@ export interface RelayEnvelope { } export interface RelayHandlers { - /** Review submitted on one of the agent's own PRs → address it. */ - addressPr(repo: string, prNumber: number): Promise; + /** + * Review submitted on one of the agent's own PRs → address it. + * @returns Whether the run completed; `false` means it failed or matched + * no workspace repo (never silently swallowed by the caller). + */ + addressPr(repo: string, prNumber: number): Promise; /** New PR conversation comment → mention/permission gates decide inside. */ handlePrComment(repo: string, prNumber: number, commentId: number): Promise; /** Tracker task changed → re-evaluate the user's query and run if ready. */ @@ -177,7 +181,12 @@ export class RelayAcquirer implements Acquirer { return; } console.log(`šŸ“Œ [relay] review feedback on ${repo}#${pr}`); - await handlers.addressPr(repo, pr); + const ok = await handlers.addressPr(repo, pr); + console.log( + ok + ? `āœ… [relay] ${repo}#${pr} feedback addressed` + : `āš ļø [relay] ${repo}#${pr} feedback run did not complete cleanly`, + ); return; } case "pr.comment_created": { diff --git a/packages/code/src/lib/relay-connect.ts b/packages/code/src/lib/relay-connect.ts index 82dad7bf..d44f34a2 100644 --- a/packages/code/src/lib/relay-connect.ts +++ b/packages/code/src/lib/relay-connect.ts @@ -17,6 +17,15 @@ import { dirname, join, resolve } from "path"; export const DEFAULT_RELAY_URL = "https://relay.devintern.com"; +/** + * Login of the DevIntern AI App bot that acts on relay-managed PRs. Its + * private key stays on DevIntern infrastructure, so a local worker can never + * resolve this identity via App auth — instead the worker injects it as a + * mention alias (GITHUB_BOT_ALIASES, see `botMentionAliases`) so that + * `@devintern-ai` mentions on relay PRs trigger review addressing. + */ +export const RELAY_BOT_LOGIN = "devintern-ai"; + export interface RelayRegistration { kind: "repo" | "source"; key: string; diff --git a/packages/code/src/lib/review-polling-acquirer.ts b/packages/code/src/lib/review-polling-acquirer.ts index e2d9690e..a9eb0af6 100644 --- a/packages/code/src/lib/review-polling-acquirer.ts +++ b/packages/code/src/lib/review-polling-acquirer.ts @@ -27,6 +27,7 @@ import { spawn } from "child_process"; import { nextScheduleOccurrence } from "./automation-config"; import type { CronOrIntervalSchedule } from "./automation-config"; +import type { AddressReviewResult } from "./address-review"; import { parseEnvInteger } from "./env-integer"; import type { RunStore } from "./run-recorder"; import type { WebhookQueue } from "./webhook-queue"; @@ -249,12 +250,18 @@ async function serializePrRun( * @param prNumber - Pull request number * @param opts - Working directory and environment for the subprocess; * the workspace worker runs from the repo's base worktree with - * per-repo env; direct callers inherit both + * per-repo env; direct callers inherit both. `onResult` + * receives the subprocess's structured outcome + * ({@link AddressReviewResult}) via the result fd. */ export function runAddressReviewViaCli( repo: string, prNumber: number, - opts: { cwd?: string; env?: Record } = {}, + opts: { + cwd?: string; + env?: Record; + onResult?: (result: AddressReviewResult) => void; + } = {}, ): Promise { return serializePrRun(repo, prNumber, () => runSubcommandViaCli("address-review", repo, prNumber, opts), @@ -387,15 +394,34 @@ function runSubcommandViaCli( subcommand: string, repo: string, prNumber: number, - opts: { cwd?: string; env?: Record } = {}, + opts: { + cwd?: string; + env?: Record; + onResult?: (result: AddressReviewResult) => void; + } = {}, ): Promise { const prUrl = `https://github.com/${repo}/pull/${prNumber}`; return new Promise((resolve) => { const child = spawn(process.execPath, [process.argv[1], subcommand, prUrl], { - stdio: "inherit", + stdio: ["inherit", "inherit", "inherit", opts.onResult ? "pipe" : "inherit"], cwd: opts.cwd, - env: opts.env ?? process.env, + env: opts.onResult + ? { ...(opts.env ?? process.env), DEVINTERN_RESULT_FD: "3" } + : (opts.env ?? process.env), }); + if (opts.onResult && child.stdio[3]) { + let output = ""; + child.stdio[3].on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + child.on("close", () => { + try { + opts.onResult?.(JSON.parse(output) as AddressReviewResult); + } catch { + // Subprocess emitted no structured result (e.g. older build). + } + }); + } child.on("close", (code) => resolve(code === 0)); child.on("error", (error) => { console.error(`āŒ Failed to spawn ${subcommand} for ${prUrl}: ${error.message}`); diff --git a/packages/code/src/lib/workspace/fleet-events.ts b/packages/code/src/lib/workspace/fleet-events.ts index f5a29fba..dd23a7be 100644 --- a/packages/code/src/lib/workspace/fleet-events.ts +++ b/packages/code/src/lib/workspace/fleet-events.ts @@ -12,8 +12,10 @@ import { runAddressReviewViaCli, runResolveConflictsViaCli } from "../review-polling-acquirer"; import type { AutomaticResolveResult } from "../review-polling-acquirer"; +import type { AddressReviewResult } from "../address-review"; import type { RepoConfig, WorkspaceConfig } from "./config"; import { buildRepoEnv, gitHubSlugFromRemote } from "./env"; +import { RELAY_BOT_LOGIN } from "../relay-connect"; import { toRoutableTask } from "./router"; import type { createFleetTaskExecutor, FleetTask, RepoManagerLike } from "./workspace-worker"; @@ -27,10 +29,16 @@ export interface FleetEventDeps { runReview?: ( repo: string, prNumber: number, - opts: { cwd: string; env: Record }, + opts: { + cwd: string; + env: Record; + onResult?: (result: AddressReviewResult) => void; + }, ) => Promise; /** Base-sync runner (injected for tests; defaults to the CLI subprocess). */ runResolve?: typeof runResolveConflictsViaCli; + /** Relay reaction request (injected for tests; defaults to the HTTP call). */ + requestRelayReactions?: (repo: string, result: AddressReviewResult) => Promise; verbose?: boolean; } @@ -56,6 +64,50 @@ export function fleetGitHubSlugs(config: WorkspaceConfig): string[] { return [...new Set(slugs)]; } +/** + * Ask the relay to mark comments as addressed under the relay App identity + * (devintern-ai) when the run was triggered by a mention of that identity but + * the local credentials could not (or should not) react. Failures are logged + * and non-fatal: the local hooray remains the primary marker. + */ +async function requestRelayReactionsViaHttp( + workspaceDir: string, + repo: string, + result: AddressReviewResult, +): Promise { + if (!result.aliasMentioned || result.unmarkedComments.length === 0) { + return; + } + const { loadRelayState } = await import("../relay-connect"); + const relayState = loadRelayState(workspaceDir); + if (!relayState?.relayToken || !relayState.relayUrl) { + return; + } + try { + const response = await fetch(`${relayState.relayUrl.replace(/\/+$/, "")}/v1/reactions`, { + method: "POST", + headers: { + Authorization: `Bearer ${relayState.relayToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ repo, comments: result.unmarkedComments }), + }); + if (!response.ok) { + console.warn( + `āš ļø [relay] could not mark ${result.unmarkedComments.length} comment(s) as @${RELAY_BOT_LOGIN}: HTTP ${response.status}`, + ); + return; + } + const body = (await response.json()) as { results?: Array<{ id: number; marked: boolean }> }; + const marked = body.results?.filter((r) => r.marked).length ?? 0; + if (marked > 0) { + console.log(`šŸŽ‰ [relay] @${RELAY_BOT_LOGIN} marked ${marked} comment(s) as addressed`); + } + } catch (error) { + console.warn(`āš ļø [relay] reaction request failed: ${(error as Error).message}`); + } +} + /** * Build the fleet review runner: address a PR's feedback from the repo's * base worktree. Used for the agent's own PRs (no gate needed) and as the @@ -68,6 +120,10 @@ export function createFleetAddressPr( ): (slug: string, prNumber: number) => Promise { const { config, workspaceDir, repoManager } = deps; const runReview = deps.runReview ?? runAddressReviewViaCli; + const requestRelayReactions = + deps.requestRelayReactions ?? + ((repo: string, result: AddressReviewResult) => + requestRelayReactionsViaHttp(workspaceDir, repo, result)); return async (slug, prNumber) => { const repo = repoBySlug(config, slug); @@ -80,10 +136,18 @@ export function createFleetAddressPr( await repoManager.ensureBareClone(repo); await repoManager.fetch(repo.name); const base = await repoManager.ensureBaseWorktree(repo); - return runReview(slug, prNumber, { + let cliResult: AddressReviewResult | undefined; + const ok = await runReview(slug, prNumber, { cwd: base, env: buildRepoEnv(repo, workspaceDir), + onResult: (result: AddressReviewResult) => { + cliResult = result; + }, }); + if (cliResult) { + await requestRelayReactions(slug, cliResult); + } + return ok; }; } diff --git a/packages/code/src/lib/workspace/workspace-worker.ts b/packages/code/src/lib/workspace/workspace-worker.ts index ceb052cd..55d076d8 100644 --- a/packages/code/src/lib/workspace/workspace-worker.ts +++ b/packages/code/src/lib/workspace/workspace-worker.ts @@ -847,7 +847,7 @@ export async function buildFleetEventAcquirers(options: { // Mode 2 relay is independent of GitHub polling credentials: tracker // envelopes only need the active tracker client. PR envelopes use the // GitHub handlers when those credentials are available. - const { loadRelayState } = await import("../relay-connect"); + const { loadRelayState, RELAY_BOT_LOGIN } = await import("../relay-connect"); const relayState = loadRelayState(workspaceDir); if (relayState || process.env.WORKER_RELAY_URL) { const relayToken = relayState?.relayToken; @@ -858,8 +858,24 @@ export async function buildFleetEventAcquirers(options: { "āš ļø Relay is configured but no relay token is stored in the workspace — re-run `devintern worker init`. Polling continues.", ); } else if (relayUrl) { + // Relay-managed PRs are associated with the DevIntern AI App identity, + // whose private key never leaves DevIntern infrastructure. Register its + // login as a mention alias so the local mention gates (including the + // address-review subprocess, which inherits this env) match + // `@devintern-ai` without needing the key. + const aliasNames = new Set( + (process.env.GITHUB_BOT_ALIASES ?? "") + .split(",") + .map((alias) => alias.trim()) + .filter(Boolean), + ); + if (!aliasNames.has(RELAY_BOT_LOGIN)) { + aliasNames.add(RELAY_BOT_LOGIN); + process.env.GITHUB_BOT_ALIASES = [...aliasNames].join(","); + } + const { RelayAcquirer } = await import("../relay-acquirer"); - const { mentionsBot } = await import("../mention-sweep-acquirer"); + const { botMentionCandidates, mentionsAnyBot } = await import("../mention-sweep-acquirer"); const execute = createFleetTaskExecutor({ config, workspaceDir, @@ -878,10 +894,24 @@ export async function buildFleetEventAcquirers(options: { state.workerState.listOpenAgentPrs(repo).some((pr) => pr.prNumber === prNumber), handlers: { addressPr: async (repo, prNumber) => { - if (addressPr) await addressPr(repo, prNumber); + if (addressPr) return addressPr(repo, prNumber); + // No GitHub credentials → review envelopes cannot be acted on. + console.warn( + `āš ļø [relay] review feedback on ${repo}#${prNumber} cannot be addressed: ` + + "GITHUB_TOKEN/GITHUB_APP_ID is not set in this workspace.", + ); + return false; }, handlePrComment: async (repo, prNumber, commentId) => { - if (!github || !handleMention) return; + if (!github || !handleMention) { + if (verbose) { + console.log( + ` [relay] ignoring comment on ${repo}#${prNumber}: no GitHub credentials ` + + "(GITHUB_TOKEN/GITHUB_APP_ID is not set in this workspace).", + ); + } + return; + } const [repoOwner, repoName] = repo.split("/") as [string, string]; const { data: comment } = await github.conditionalGet<{ id: number; @@ -892,7 +922,8 @@ export async function buildFleetEventAcquirers(options: { }>(`/repos/${repo}/issues/comments/${commentId}`, repoOwner, repoName); if (!comment) return; const botName = await github.getBotUsername(repoOwner, repoName); - if (!botName || !mentionsBot(comment.body, botName)) return; + const botNames = botMentionCandidates(botName); + if (botNames.length === 0 || !mentionsAnyBot(comment.body, botNames)) return; await handleMention(repo, comment, prNumber); }, evaluateTask, diff --git a/packages/code/tests/fleet-events.test.ts b/packages/code/tests/fleet-events.test.ts index 288fa730..3e077cfa 100644 --- a/packages/code/tests/fleet-events.test.ts +++ b/packages/code/tests/fleet-events.test.ts @@ -15,6 +15,7 @@ import { } from "../src/lib/workspace/fleet-events"; import { createFleetTaskExecutor } from "../src/lib/workspace/workspace-worker"; import type { RepoManagerLike } from "../src/lib/workspace/workspace-worker"; +import type { AddressReviewResult } from "../src/lib/address-review"; import { createRepoRunLock, openWorkspaceState } from "../src/lib/workspace/state"; import type { WorkspaceState } from "../src/lib/workspace/state"; @@ -179,6 +180,52 @@ describe("fleet event handlers", () => { expect(reviews).toHaveLength(0); }); + test("addressPr forwards the CLI result to the relay reaction request", async () => { + const relayRequests: Array<{ repo: string; result: unknown }> = []; + const addressPr = createFleetAddressPr({ + ...deps(), + requestRelayReactions: async (repo, result) => { + relayRequests.push({ repo, result }); + }, + runReview: async ( + slug: string, + prNumber: number, + opts: { cwd: string; onResult?: (result: AddressReviewResult) => void }, + ) => { + opts.onResult?.({ + aliasMentioned: true, + unmarkedComments: [{ id: 3892157438, target: "review" }], + }); + return true; + }, + }); + + const ok = await addressPr("acme/backend", 42); + expect(ok).toBe(true); + expect(relayRequests).toEqual([ + { + repo: "acme/backend", + result: { + aliasMentioned: true, + unmarkedComments: [{ id: 3892157438, target: "review" }], + }, + }, + ]); + }); + + test("addressPr does not request relay reactions without a CLI result", async () => { + let requested = 0; + const addressPr = createFleetAddressPr({ + ...deps(), + requestRelayReactions: async () => { + requested++; + }, + }); + + await addressPr("acme/backend", 42); + expect(requested).toBe(0); + }); + test("base sync uses the fleet repo worktree and forwards expected SHAs", async () => { const resolve = createFleetResolveConflicts(deps()); const result = await resolve("acme/backend", 42, { diff --git a/packages/code/tests/mention-sweep-acquirer.test.ts b/packages/code/tests/mention-sweep-acquirer.test.ts index 4c97f730..6ed5a46c 100644 --- a/packages/code/tests/mention-sweep-acquirer.test.ts +++ b/packages/code/tests/mention-sweep-acquirer.test.ts @@ -5,6 +5,8 @@ import { tmpdir } from "os"; import { MentionSweepAcquirer, + botMentionAliases, + botMentionCandidates, extractPrNumber, mentionsBot, } from "../src/lib/mention-sweep-acquirer"; @@ -31,6 +33,86 @@ describe("mentionsBot", () => { }); }); +describe("bot mention aliases", () => { + const ALIAS_ENV = "GITHUB_BOT_ALIASES"; + let saved: string | undefined; + + beforeEach(() => { + saved = process.env[ALIAS_ENV]; + }); + + afterEach(() => { + if (saved === undefined) { + delete process.env[ALIAS_ENV]; + } else { + process.env[ALIAS_ENV] = saved; + } + }); + + test("parses a comma-separated list and ignores blanks", () => { + process.env[ALIAS_ENV] = "devintern-ai, devintern-internal [bot] ,,"; + expect(botMentionAliases()).toEqual(["devintern-ai", "devintern-internal [bot]"]); + }); + + test("returns empty without the env var", () => { + delete process.env[ALIAS_ENV]; + expect(botMentionAliases()).toEqual([]); + }); + + test("candidates dedupe the resolved login with aliases", () => { + process.env[ALIAS_ENV] = "devintern-ai,devintern[bot]"; + expect(botMentionCandidates("devintern[bot]")).toEqual(["devintern[bot]", "devintern-ai"]); + expect(botMentionCandidates(null)).toEqual(["devintern-ai", "devintern[bot]"]); + }); + + test("aliases make the sweep work without resolvable App auth", async () => { + process.env[ALIAS_ENV] = "devintern-ai"; + const dbPath = join( + tmpdir(), + `ms-alias-${Date.now()}-${Math.random().toString(36).slice(2)}.db`, + ); + const workerState = new WorkerState(dbPath); + const queue = new WebhookQueue({ dbPath }); + const handled: Array<{ id: number; prNumber: number }> = []; + const acquirer = new MentionSweepAcquirer({ + repo: "acme/widgets", + intervalSeconds: 60, + workerState, + queue, + github: { + fetchIssueCommentsSince: async () => [ + { + id: 7, + body: "@devintern-ai relying on local envs is fragile", + user: { login: "reviewer", type: "User" }, + created_at: new Date(Date.now() + 60_000).toISOString(), + html_url: "https://github.com/acme/widgets/pull/5#issuecomment-7", + issue_url: "https://api.github.com/repos/acme/widgets/issues/5", + }, + ], + fetchReviewCommentsSince: async () => [], + getBotUsername: async () => null, // no App credentials locally + getPr: async (prNumber) => ({ number: prNumber, state: "open" }), + postComment: async () => {}, + }, + handleMention: async (c, prNumber) => { + handled.push({ id: c.id, prNumber }); + }, + }); + + try { + await acquirer.tick(); + expect(handled).toEqual([{ id: 7, prNumber: 5 }]); + } finally { + workerState.close(); + queue.close(); + for (const suffix of ["", "-wal", "-shm"]) { + rmSync(`${dbPath}${suffix}`, { force: true }); + } + } + }); +}); + describe("extractPrNumber", () => { const base: Omit = { id: 1, diff --git a/packages/code/tests/relay-acquirer.test.ts b/packages/code/tests/relay-acquirer.test.ts index 5947cfb9..47157131 100644 --- a/packages/code/tests/relay-acquirer.test.ts +++ b/packages/code/tests/relay-acquirer.test.ts @@ -95,6 +95,7 @@ describe("RelayAcquirer", () => { handlers: { addressPr: async (repo, pr) => { log.addressed.push([repo, pr]); + return true; }, handlePrComment: async (repo, pr, commentId) => { log.comments.push([repo, pr, commentId]); @@ -234,7 +235,7 @@ describe("RelayAcquirer", () => { fetchImpl, isAgentPr: () => false, handlers: { - addressPr: async () => {}, + addressPr: async () => false, handlePrComment: async () => {}, evaluateTask: async (taskKey) => { if (taskKey === "BOOM-1") {