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
79 changes: 78 additions & 1 deletion src/lib/pr-followup-ingestion.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import {
classifyFeedback,
parseAiReviewerFindings,
isInformationalComment,
computeEvidenceKey,
isAllowedBotAuthor,
isAllowedBranchOwner,
Expand Down Expand Up @@ -781,3 +782,79 @@ describe("no hardcoded agent or repo names", () => {
delete process.env.PR_FOLLOWUP_BRANCH_OWNERS;
});
});

// ─── Informational bot comments ─────────────────────────────────────────────

// The real body shape from misospace/llmkube-images#114: a size-diff table
// reporting no change at all, which blocked a mergeable PR in NEEDS_HUMAN.
const SIZE_DIFF_COMMENT = `## 📦 App Size Analysis

| OS/Platform | Previous | Current | Change | Trend |
| :---------- | :-------: | :-------: | :--------: | :---: |
| linux/amd64 | 191.12 MB | 191.12 MB | +0 B (+0%) | 🔄 |
<!-- Sticky Pull Request Commentapp-size-diff-llmkube-coder-node -->`;

describe("isInformationalComment", () => {
it("flags sticky CI comments", () => {
expect(isInformationalComment(SIZE_DIFF_COMMENT)).toBe(true);
});

it("flags dispatch's own BLOCKED notice", () => {
expect(isInformationalComment("<!-- dispatch-pr-fix-blocked -->\n> ⚠️ needs human")).toBe(true);
});

it("does NOT flag an ai-pr-reviewer comment even with a marker", () => {
const body = `<!-- Sticky Pull Request Comment -->
<!-- ai-pr-reviewer:${JSON.stringify({ review_result: "issues", open_findings: [{ severity: "blocker", message: "no upgrade performed" }] })} -->
## Review: Request Changes`;
expect(isInformationalComment(body)).toBe(false);
});

it("does NOT flag ordinary review prose", () => {
expect(isInformationalComment("Please change `fetch` to `axios` here.")).toBe(false);
expect(isInformationalComment("")).toBe(false);
expect(isInformationalComment(null)).toBe(false);
});
});

describe("ingestCommentEvent — informational comments", () => {
beforeEach(() => { vi.restoreAllMocks(); });

it("does not enqueue a size-diff CI comment (llmkube-images#114 regression)", async () => {
process.env.PR_FOLLOWUP_BOT_IDENTITIES = "itsmiso-ai";
const client = makeClient();

const key = await ingestCommentEvent(client, {
repoFullName: "misospace/llmkube-images",
prNumber: 114,
branch: "foreman/wl-misospace-llmkube-images-89/issue-89",
url: "https://github.com/misospace/llmkube-images/pull/114",
title: "Add .dockerignore files to all app directories",
author: "itsmiso-ai",
commentBody: SIZE_DIFF_COMMENT,
commentId: "c-size-diff",
});

expect(key).toBeNull();
expect(client.items).toHaveLength(0);
});

it("still enqueues a genuine prose comment", async () => {
process.env.PR_FOLLOWUP_BOT_IDENTITIES = "itsmiso-ai";
const client = makeClient();

await ingestCommentEvent(client, {
repoFullName: "misospace/llmkube-images",
prNumber: 115,
branch: "foreman/x",
url: "https://github.com/misospace/llmkube-images/pull/115",
title: "Something",
author: "itsmiso-ai",
commentBody: "Change `fetch` to `axios` for better error handling",
commentId: "c-real",
});

expect(client.items).toHaveLength(1);
expect(client.items[0].lane).toBe("NORMAL");
});
});
39 changes: 38 additions & 1 deletion src/lib/pr-followup-ingestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,40 @@ export function parseAiReviewerFindings(body: string | undefined | null): AiRevi
}
}

// ─── Informational bot comments ─────────────────────────────────────────────

/**
* Markers identifying comments that report state rather than request work:
* CI size-diff tables, coverage summaries, and dispatch's own BLOCKED notice.
* The sticky-comment convention (marvinpinto/sticky-pull-request-comment and
* friends) embeds a hidden marker so the action can update its comment in
* place — which makes it a structural discriminator, not a prose guess.
*/
const INFORMATIONAL_COMMENT_MARKERS = [
"<!-- Sticky Pull Request Comment",
"<!-- dispatch-pr-fix-blocked -->",
];

/**
* Report whether a comment is informational rather than review feedback.
*
* The comment path previously ingested every non-author comment on a bot PR,
* so a CI bot posting "size change: +0 B" became a REVIEW_FEEDBACK item;
* classifyFeedback finds no actionable pattern in a markdown table, defaults
* to needs_human, and the PR is BLOCKED on a human forever. Observed on
* misospace/llmkube-images#114: a mergeable PR blocked by a size-diff table
* reporting no change at all.
*
* An ai-pr-reviewer comment is exempt even when it carries a marker: it ships
* a structured findings payload and is genuine review feedback that the
* routing in `reviewLane` is built to act on.
*/
export function isInformationalComment(body: string | undefined | null): boolean {
if (!body) return false;
if (parseAiReviewerFindings(body)) return false;
return INFORMATIONAL_COMMENT_MARKERS.some((marker) => body.includes(marker));
}

// ─── Evidence Key ───────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -274,7 +308,10 @@ const PROBLEMATIC_MERGE_STATES = ["behind", "dirty", "unstable", "has_hooks"];
const INGEST_DESCRIPTORS: Record<PrFollowupEvent["eventType"], IngestDescriptor> = {
comment: {
isIngestible: (event) => Boolean(event.body && event.id),
gate: () => true,
// Informational bot comments (CI tables, dispatch's own BLOCKED notice)
// are state reports, not work requests — ingesting them strands mergeable
// PRs in NEEDS_HUMAN.
gate: (event) => !isInformationalComment(event.body),
filterTerminalPr: false,
sourceId: (event) => event.id,
workItem: (event) => {
Expand Down
Loading