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
4 changes: 3 additions & 1 deletion .claude/skills/contributing-to-gittensory/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ All tools are metadata-only (no source upload). Run in this order:
6. `gittensory_predict_gate` — `{login, owner, repo, title, body, labels, linkedIssues}` → predicted
conclusion + blockers + warnings + readiness score.

(Auth'd extras: `gittensory_preflight_pr` / `…_local_diff` for lane fit + collision + queue health.)
(Auth'd extras: `gittensory_preflight_pr` / `…_local_diff` for lane fit + collision + queue health;
`gittensory_get_pr_ai_review_findings` — `{login, owner, repo, pullNumber}` → structured post-submission
AI-review inline findings (category/path/severity) for your own PR.)

---

Expand Down
7 changes: 4 additions & 3 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4499,19 +4499,20 @@ export async function getLatestPublishedAiReview(
repoFullName: string,
pullNumber: number,
mode: string,
): Promise<{ notes: string; reviewerCount: number; findings: AdvisoryFinding[]; metadata?: Record<string, unknown> | undefined } | null> {
): Promise<{ notes: string; reviewerCount: number; findings: AdvisoryFinding[]; headSha?: string | undefined; metadata?: Record<string, unknown> | undefined } | null> {
const row = await env.DB
.prepare(
"SELECT notes, reviewer_count AS reviewerCount, findings_json AS findingsJson, metadata_json AS metadataJson FROM ai_review_cache WHERE repo_full_name = ? AND pull_number = ? AND ai_review_mode = ? AND published_at IS NOT NULL ORDER BY published_at DESC LIMIT 1",
"SELECT notes, reviewer_count AS reviewerCount, head_sha AS headSha, findings_json AS findingsJson, metadata_json AS metadataJson FROM ai_review_cache WHERE repo_full_name = ? AND pull_number = ? AND ai_review_mode = ? AND published_at IS NOT NULL ORDER BY published_at DESC LIMIT 1",
)
.bind(repoFullName, pullNumber, mode)
.first<{ notes: string; reviewerCount: number; findingsJson: string | null; metadataJson: string | null }>();
.first<{ notes: string; reviewerCount: number; headSha: string; findingsJson: string | null; metadataJson: string | null }>();
if (!row) return null;
const metadata = parseJson<Record<string, unknown>>(row.metadataJson, {});
return {
notes: row.notes,
reviewerCount: row.reviewerCount,
findings: parseJson<AdvisoryFinding[]>(row.findingsJson, []),
...(row.headSha ? { headSha: row.headSha } : {}),
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
};
}
Expand Down
136 changes: 136 additions & 0 deletions src/mcp/pr-ai-review-findings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { getLatestPublishedAiReview } from "../db/repositories";
import { classifyFindingCategory, FINDING_CATEGORIES, isFindingCategory, type FindingCategory } from "../review/finding-category-classify";
import type { InlineFinding } from "../services/ai-review";
import { resolveRepositorySettings } from "../settings/repository-settings";

/** Metadata key written by the review processor when caching a fresh AI review (#4519). */
export const INLINE_FINDINGS_METADATA_KEY = "inlineFindings" as const;

export type StructuredAiReviewFinding = {
category: FindingCategory;
path: string;
severity: InlineFinding["severity"];
line: number;
body: string;
};

export type PrAiReviewFindingsPayload =
| {
status: "ready";
repoFullName: string;
pullNumber: number;
login: string;
headSha: string | null;
findings: StructuredAiReviewFinding[];
categoryCounts: Partial<Record<FindingCategory, number>>;
}
| {
status: "not_found";
repoFullName: string;
pullNumber: number;
login: string;
findings: [];
categoryCounts: Record<string, never>;
}
| {
status: "ai_review_off";
repoFullName: string;
pullNumber: number;
login: string;
findings: [];
categoryCounts: Record<string, never>;
};

function isInlineFindingSeverity(value: unknown): value is InlineFinding["severity"] {
return value === "blocker" || value === "nit";
}

/** Parse line-anchored findings persisted in `ai_review_cache.metadata_json.inlineFindings`. */
export function parseStoredInlineFindings(metadata: Record<string, unknown> | undefined): InlineFinding[] {
const raw = metadata?.[INLINE_FINDINGS_METADATA_KEY];
if (!Array.isArray(raw)) return [];
const findings: InlineFinding[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const candidate = entry as Record<string, unknown>;
if (typeof candidate.path !== "string" || candidate.path.length === 0) continue;
if (typeof candidate.body !== "string") continue;
if (!isInlineFindingSeverity(candidate.severity)) continue;
const line = candidate.line;
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) continue;
findings.push({
path: candidate.path,
line,
severity: candidate.severity,
body: candidate.body,
...(isFindingCategory(candidate.category) ? { category: candidate.category } : {}),
});
}
return findings;
}

/** Normalize inline findings to the structured MCP shape, applying the same category fallback as the PR comment. */
export function buildStructuredAiReviewFindings(inlineFindings: InlineFinding[]): StructuredAiReviewFinding[] {
return inlineFindings.map((finding) => ({
category: finding.category ?? classifyFindingCategory(finding),
path: finding.path,
severity: finding.severity,
line: finding.line,
body: finding.body,
}));
}

/** Count findings per category using the same rules as `buildFindingCategoryCollapsible`. */
export function buildFindingCategoryCounts(findings: StructuredAiReviewFinding[]): Partial<Record<FindingCategory, number>> {
const counts: Partial<Record<FindingCategory, number>> = {};
for (const finding of findings) {
counts[finding.category] = (counts[finding.category] ?? 0) + 1;
}
return counts;
}

/** Ordered category count rows matching the human-facing collapsible table (security-first). */
export function orderedFindingCategoryCountRows(counts: Partial<Record<FindingCategory, number>>): Array<{ category: FindingCategory; count: number }> {
return FINDING_CATEGORIES.flatMap((category) => {
const count = counts[category];
if (!count) return [];
return [{ category, count }];
});
}

function sameLogin(value: string | null | undefined, login: string): boolean {
return typeof value === "string" && value.toLowerCase() === login.toLowerCase();
}

/** Load a submitted PR's published AI-review inline findings for MCP (#4519). */
export async function loadPrAiReviewFindings(
env: Env,
args: { repoFullName: string; pullNumber: number; login: string },
): Promise<PrAiReviewFindingsPayload> {
const base = { repoFullName: args.repoFullName, pullNumber: args.pullNumber, login: args.login.toLowerCase() };
const settings = await resolveRepositorySettings(env, args.repoFullName);
if (settings.aiReviewMode === "off") {
return { status: "ai_review_off", ...base, findings: [], categoryCounts: {} };
}

const published = await getLatestPublishedAiReview(env, args.repoFullName, args.pullNumber, settings.aiReviewMode);
if (!published) {
return { status: "not_found", ...base, findings: [], categoryCounts: {} };
}

const inlineFindings = parseStoredInlineFindings(published.metadata);
const findings = buildStructuredAiReviewFindings(inlineFindings);
return {
status: "ready",
...base,
headSha: published.headSha ?? null,
findings,
categoryCounts: buildFindingCategoryCounts(findings),
};
}

export function assertContributorOwnsPullRequest(authorLogin: string | null | undefined, login: string): void {
if (!sameLogin(authorLogin, login)) {
throw new Error("Forbidden: this tool only returns AI-review findings for your own pull requests.");
}
}
76 changes: 76 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
runFindOpportunities,
validateFindOpportunitiesInput,
} from "./find-opportunities";
import { loadPrAiReviewFindings, assertContributorOwnsPullRequest } from "./pr-ai-review-findings";
import {
MAX_ISSUE_RAG_OWNER_LENGTH,
MAX_ISSUE_RAG_REPO_LENGTH,
Expand Down Expand Up @@ -855,6 +856,33 @@ const prOutcomeOutputSchema = {
outcomes: z.unknown().optional(),
};

const loginRepoPullShape = {
login: z.string().min(1),
owner: z.string().min(1),
repo: z.string().min(1),
pullNumber: z.number().int().positive(),
};

const prAiReviewFindingsOutputSchema = {
status: z.enum(["ready", "not_found", "ai_review_off"]),
repoFullName: z.string().optional(),
pullNumber: z.number().optional(),
login: z.string().optional(),
headSha: z.string().nullable().optional(),
findings: z
.array(
z.object({
category: z.string(),
path: z.string(),
severity: z.enum(["blocker", "nit"]),
line: z.number(),
body: z.string(),
}),
)
.optional(),
categoryCounts: z.record(z.string(), z.number()).optional(),
};

const predictGateShape = {
login: z.string().min(1),
owner: z.string().min(1),
Expand Down Expand Up @@ -1638,6 +1666,17 @@ export class GittensoryMcp {
async (input) => this.toolResult(await this.prOutcomes(input.login, input.limit)),
);

server.registerTool(
"gittensory_get_pr_ai_review_findings",
{
description:
"Return a submitted pull request's real AI-review inline findings as structured JSON (category, path, severity, line, body) — the same categorization the PR comment uses. Post-submission only; self-scoped to the authenticated login's own PRs on repos you can access.",
inputSchema: loginRepoPullShape,
outputSchema: prAiReviewFindingsOutputSchema,
},
async (input) => this.toolResult(await this.getPrAiReviewFindings(input)),
);

server.registerTool(
"gittensory_list_notifications",
{
Expand Down Expand Up @@ -2959,6 +2998,43 @@ export class GittensoryMcp {
};
}

private async getPrAiReviewFindings(input: z.infer<z.ZodObject<typeof loginRepoPullShape>>): Promise<ToolPayload> {
this.requireContributorAccess(input.login);
const repoFullName = `${input.owner}/${input.repo}`;
await this.requireRepoAccess(repoFullName);
const pullRequest = await getPullRequest(this.env, repoFullName, input.pullNumber);
if (!pullRequest) {
return {
summary: `No pull request ${repoFullName}#${input.pullNumber}.`,
data: {
status: "not_found",
repoFullName,
pullNumber: input.pullNumber,
login: input.login.toLowerCase(),
findings: [],
categoryCounts: {},
},
};
}
assertContributorOwnsPullRequest(pullRequest.authorLogin, input.login);
const payload = await loadPrAiReviewFindings(this.env, {
repoFullName,
pullNumber: input.pullNumber,
login: input.login,
});
const findingCount = payload.status === "ready" ? payload.findings.length : 0;
const summary =
payload.status === "ready"
? `${findingCount} AI-review finding(s) on ${repoFullName}#${input.pullNumber}.`
: payload.status === "ai_review_off"
? `AI review is off for ${repoFullName}; no findings to return for #${input.pullNumber}.`
: `No published AI review findings for ${repoFullName}#${input.pullNumber}.`;
return {
summary,
data: payload as unknown as Record<string, unknown>,
};
}

private async listNotifications(login: string): Promise<ToolPayload> {
this.requireContributorAccess(login);
const deliveries = await listNotificationDeliveriesForRecipient(this.env, login, { channel: "badge", limit: 50 });
Expand Down
6 changes: 6 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9941,6 +9941,12 @@ async function maybePublishPrPublicSurface(
/* v8 ignore next -- runAiReviewForAdvisory (the sole path reaching here) always sets metadata on its "ok" returns; the nullish fallback is a type-level (optional field) safeguard, not a reachable runtime path. */
...(aiReview.metadata ?? {}),
inputFingerprint,
// Persist line-anchored findings for post-submission MCP readback (#4519). Inline comments
// themselves are still only posted on a fresh review (see inlineFindings hoisting above);
// this metadata is read-only structured output, not a cache-replay trigger.
...(aiReview.inlineFindings && aiReview.inlineFindings.length > 0
? { inlineFindings: aiReview.inlineFindings }
: {}),
},
},
).catch((error) => {
Expand Down
1 change: 1 addition & 0 deletions src/services/subnet-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const CONTRIBUTION_MCP_TOOLS: ReadonlyArray<{ name: string; summary: string }> =
{ name: "gittensory_validate_linked_issue", summary: "Confirm whether a planned PR has a linked issue before opening it." },
{ name: "gittensory_preflight_pr", summary: "Preflight a planned PR for lane fit, duplicate risk, and review burden." },
{ name: "gittensory_monitor_open_prs", summary: "Track your open PRs and what to clean up first." },
{ name: "gittensory_get_pr_ai_review_findings", summary: "Read structured AI-review findings on your submitted PR (category, path, severity)." },
{ name: "gittensory_list_notifications", summary: "See review feedback (e.g. changes requested) on your PRs." },
{ name: "gittensory_agent_plan_next_work", summary: "Suggest useful next gittensor contribution actions from current repo and PR context." },
];
Expand Down
18 changes: 15 additions & 3 deletions test/unit/ai-review-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,15 +422,15 @@ describe("AI review cache (#1)", () => {
// A newer head SHA exists (the contributor pushed again), but was never independently published.
await putCachedAiReview(env, "o/r", 51, "sha2", "block", { notes: "never published", reviewerCount: 1 });

expect(await getLatestPublishedAiReview(env, "o/r", 51, "block")).toEqual({ notes: "first review", reviewerCount: 1, findings: [] });
expect(await getLatestPublishedAiReview(env, "o/r", 51, "block")).toEqual({ notes: "first review", reviewerCount: 1, findings: [], headSha: "sha1" });
});

it("respects the ai_review_mode filter, same as getCachedAiReview", async () => {
const env = createTestEnv();
await putCachedAiReview(env, "o/r", 52, "sha1", "advisory", { notes: "advisory mode", reviewerCount: 1 });
await markAiReviewPublished(env, "o/r", 52, "sha1");
expect(await getLatestPublishedAiReview(env, "o/r", 52, "block")).toBeNull();
expect(await getLatestPublishedAiReview(env, "o/r", 52, "advisory")).toEqual({ notes: "advisory mode", reviewerCount: 1, findings: [] });
expect(await getLatestPublishedAiReview(env, "o/r", 52, "advisory")).toEqual({ notes: "advisory mode", reviewerCount: 1, findings: [], headSha: "sha1" });
});

it("round-trips findings and metadata like getCachedAiReview", async () => {
Expand All @@ -446,6 +446,7 @@ describe("AI review cache (#1)", () => {
notes: "held review",
reviewerCount: 2,
findings: [{ code: "ai_review_split", severity: "critical", title: "Split", detail: "One reviewer blocked." }],
headSha: "sha1",
metadata: { inputFingerprint: "fp-v1" },
});
});
Expand All @@ -462,11 +463,22 @@ describe("AI review cache (#1)", () => {
await putCachedAiReview(env, "o/r", 54, "sha2", "block", { notes: "newer published review", reviewerCount: 1 });
await markAiReviewPublished(env, "o/r", 54, "sha2");

expect(await getLatestPublishedAiReview(env, "o/r", 54, "block")).toEqual({ notes: "newer published review", reviewerCount: 1, findings: [] });
expect(await getLatestPublishedAiReview(env, "o/r", 54, "block")).toEqual({ notes: "newer published review", reviewerCount: 1, findings: [], headSha: "sha2" });
} finally {
vi.useRealTimers();
}
});

it("omits headSha from the payload when the stored head_sha is empty", async () => {
const env = createTestEnv();
await env.DB.prepare(
`INSERT INTO ai_review_cache (repo_full_name, pull_number, head_sha, ai_review_mode, notes, reviewer_count, findings_json, metadata_json, cacheable, published_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind("o/r", 55, "", "block", "empty head", 1, "[]", "{}", 1, "2026-07-09T00:00:00.000Z", "2026-07-09T00:00:00.000Z")
.run();
expect(await getLatestPublishedAiReview(env, "o/r", 55, "block")).toEqual({ notes: "empty head", reviewerCount: 1, findings: [] });
});
});

describe("countPublishedAiReviewHeads — auto_pause_after_reviewed_commits (#2042)", () => {
Expand Down
Loading