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
20 changes: 15 additions & 5 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ import type { CheckFailureDetail, MergeReadiness } from "../review/unified-comme
import { buildIssueSlopAssessment, buildSlopAssessment, type SlopBand } from "../signals/slop";
import { runGittensoryAiSlopAdvisory } from "../services/ai-slop";
import { decidePublicSurface } from "../signals/settings-preview";
import { buildFocusManifestGuidance, type ReviewProfile } from "../signals/focus-manifest";
import { buildFocusManifestGuidance, resolveReviewPathInstructions, resolveReviewPromptOverrides, type ReviewPathInstruction, type ReviewProfile } from "../signals/focus-manifest";
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { resolveRepositorySettings } from "../settings/repository-settings";
import type { LocalBranchAnalysisInput } from "../signals/local-branch";
Expand Down Expand Up @@ -2026,6 +2026,10 @@ export async function runAiReviewForAdvisory(
// manifest. Threaded in (not loaded here) so the AI review path makes no extra manifest fetch — absent ⇒
// null ⇒ balanced ⇒ the reviewer prompt is byte-identical.
reviewProfile?: ReviewProfile | null | undefined;
// `.gittensory.yml` review.path_instructions (#review-path-instructions), resolved by the caller from the
// cached manifest. The CONFIG (not a fetch) is threaded in; the per-PR glob match against `files` happens
// here (pure), so the AI path makes no extra manifest fetch. Absent/empty ⇒ byte-identical reviewer prompt.
reviewPathInstructions?: ReviewPathInstruction[] | undefined;
},
): Promise<{ notes: string; reviewerCount: number } | undefined> {
const packAllowsAnyAuthorBlockingReview = args.settings.gatePack === "oss-anti-slop" && args.settings.aiReviewMode === "block";
Expand Down Expand Up @@ -2092,6 +2096,10 @@ export async function runAiReviewForAdvisory(
grounding,
ragContext,
profile: args.reviewProfile ?? null,
pathGuidance: resolveReviewPathInstructions(
args.reviewPathInstructions ?? [],
files.map((file) => file.path),
),
});
if (result.status !== "ok") return undefined;
if (result.consensusDefect) {
Expand Down Expand Up @@ -2520,10 +2528,11 @@ async function maybePublishPrPublicSurface(
// to keep gate-only and advisory-sweep repos free of an extra file resolve.
const aiReviewWillRun = !webhook.skipAiReview && settings.aiReviewMode !== "off" && Boolean(advisory.headSha);
if (aiReviewWillRun) {
// `.gittensory.yml` review.profile (#review-profile): resolve from the manifest (cached from settings
// resolution, so a cheap cache hit — no extra fetch) and thread it into the AI review so chill/assertive
// shapes the write-up. Absent ⇒ null ⇒ balanced ⇒ byte-identical prompt. Fail-safe to null on any read error.
const reviewProfile = (await loadRepoFocusManifest(env, repoFullName).catch(() => null))?.review.profile ?? null;
// `.gittensory.yml` review.profile + review.path_instructions (#review-profile / #review-path-instructions):
// resolve from the manifest (cached from settings resolution, so a cheap cache hit — no extra fetch) and
// thread them into the AI review. Profile shapes nitpickiness; path-instructions add per-path guidance.
// Absent ⇒ byte-identical prompt. Fail-safe to defaults on any read error (resolveReviewPromptOverrides).
const { profile: reviewProfile, pathInstructions: reviewPathInstructions } = resolveReviewPromptOverrides(await loadRepoFocusManifest(env, repoFullName).catch(() => null));
aiReview = await runAiReviewForAdvisory(env, {
settings,
advisory,
Expand All @@ -2533,6 +2542,7 @@ async function maybePublishPrPublicSurface(
confirmedContributor,
files: await getReviewFiles(),
reviewProfile,
reviewPathInstructions,
});
}

Expand Down
12 changes: 11 additions & 1 deletion src/services/ai-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ export type GittensoryAiReviewInput = {
* consensus-defect pass still runs the same), just how much advisory detail the prose carries.
*/
profile?: ReviewProfile | null | undefined;
/**
* `.gittensory.yml` `review.path_instructions` (#review-path-instructions), pre-resolved by the caller to the
* entries whose glob matched THIS PR's changed files (via `resolveReviewPathInstructions`) — a ready-to-append
* prompt section. Absent / empty ⇒ the reviewer prompt is byte-identical. Public-safe by construction (the
* instructions passed the manifest's public-safe filter at parse time).
*/
pathGuidance?: string | null | undefined;
};

/** A consensus critical defect, already public-safe, ready to become a gate blocker finding. */
Expand Down Expand Up @@ -277,7 +284,10 @@ const REVIEW_PROFILE_SUFFIX: Record<"chill" | "assertive", string> = {
function buildSystemPrompt(input: GittensoryAiReviewInput): string {
const groundingSuffix = input.grounding?.systemSuffix ?? "";
const profileSuffix = input.profile === "chill" || input.profile === "assertive" ? REVIEW_PROFILE_SUFFIX[input.profile] : "";
return `${REVIEW_SYSTEM_PROMPT}${groundingSuffix}${profileSuffix}`;
// `.gittensory.yml` review.path_instructions (#review-path-instructions): the caller pre-resolved the entries
// matching this PR's files into a prompt section; empty ⇒ nothing appended (byte-identical).
const pathSuffix = input.pathGuidance?.trim() ? input.pathGuidance : "";
return `${REVIEW_SYSTEM_PROMPT}${groundingSuffix}${profileSuffix}${pathSuffix}`;
}

/** One Workers-AI opinion with a per-slot reliable fallback and a 3× retry on the primary. */
Expand Down
85 changes: 81 additions & 4 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,18 @@ export type FocusManifestReviewConfig = {
fields: Partial<Record<ReviewFieldKey, boolean>>;
/** `review.profile`: chill / balanced / assertive. null (absent) = balanced = byte-identical reviewer prompt. */
profile: ReviewProfile | null;
/** `review.path_instructions`: per-path natural-language guidance handed to the AI reviewer when the PR's
* changed files match the glob. Empty (default) ⇒ byte-identical reviewer prompt. (#review-path-instructions) */
pathInstructions: ReviewPathInstruction[];
};

/** One `review.path_instructions[]` entry: a manifest path glob + the public-safe instructions to apply when a
* changed file matches it. */
export type ReviewPathInstruction = { path: string; instructions: string };

// A hard cap so a hostile/huge manifest can't bloat the reviewer prompt (mirrors REVIEW_FIELD_KEYS discipline).
const MAX_PATH_INSTRUCTIONS = 50;

/**
* Normalized maintainer focus manifest. Repo owners declare which work areas are wanted,
* blocked, or preferred so Gittensory guidance can explain why a path is encouraged or
Expand Down Expand Up @@ -195,7 +205,7 @@ const EMPTY_MANIFEST: FocusManifest = {
publicNotes: [],
gate: { ...EMPTY_GATE_CONFIG },
settings: {},
review: { present: false, footerText: null, note: null, fields: {}, profile: null },
review: { present: false, footerText: null, note: null, fields: {}, profile: null, pathInstructions: [] },
warnings: [],
};

Expand All @@ -208,7 +218,7 @@ export function isFocusManifestPublicSafe(text: string): boolean {
}

function emptyManifest(source: FocusManifestSource, warnings: string[] = []): FocusManifest {
return { ...EMPTY_MANIFEST, source, warnings, gate: { ...EMPTY_GATE_CONFIG }, settings: {}, review: { present: false, footerText: null, note: null, fields: {}, profile: null } };
return { ...EMPTY_MANIFEST, source, warnings, gate: { ...EMPTY_GATE_CONFIG }, settings: {}, review: { present: false, footerText: null, note: null, fields: {}, profile: null, pathInstructions: [] } };
}

function normalizeStringList(value: JsonValue | undefined, field: string, warnings: string[]): string[] {
Expand Down Expand Up @@ -486,7 +496,7 @@ function parsePublicSafeText(value: JsonValue | undefined, field: string, warnin
* throws; invalid/unsafe values are dropped with warnings.
*/
function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestReviewConfig {
const empty: FocusManifestReviewConfig = { present: false, footerText: null, note: null, fields: {}, profile: null };
const empty: FocusManifestReviewConfig = { present: false, footerText: null, note: null, fields: {}, profile: null, pathInstructions: [] };
if (value === undefined || value === null) return empty;
if (typeof value !== "object" || Array.isArray(value)) {
warnings.push(`Manifest field "review" must be a mapping; ignoring it.`);
Expand All @@ -507,7 +517,51 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo
const footerText = footerRecord ? parsePublicSafeText(footerRecord.text, "review.footer.text", warnings) : null;
const note = parsePublicSafeText(r.note, "review.note", warnings);
const profile = parseReviewProfile(r.profile, warnings);
return { present: footerText !== null || note !== null || profile !== null || Object.keys(fields).length > 0, footerText, note, fields, profile };
const pathInstructions = parseReviewPathInstructions(r.path_instructions, warnings);
return {
present: footerText !== null || note !== null || profile !== null || pathInstructions.length > 0 || Object.keys(fields).length > 0,
footerText,
note,
fields,
profile,
pathInstructions,
};
}

/** Parse `review.path_instructions` — an array of `{ path, instructions }` entries. Each must have a non-empty
* string `path` (a manifest glob) and PUBLIC-SAFE string `instructions`; invalid/unsafe entries are dropped with
* a warning. Capped at MAX_PATH_INSTRUCTIONS so a huge manifest can't bloat the reviewer prompt. */
function parseReviewPathInstructions(value: JsonValue | undefined, warnings: string[]): ReviewPathInstruction[] {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) {
warnings.push(`Manifest "review.path_instructions" must be a list of { path, instructions }; ignoring it.`);
return [];
}
const out: ReviewPathInstruction[] = [];
for (const [index, entry] of value.entries()) {
if (out.length >= MAX_PATH_INSTRUCTIONS) {
warnings.push(`Manifest "review.path_instructions" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`);
break;
}
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
warnings.push(`Manifest "review.path_instructions[${index}]" must be a mapping with path + instructions; ignoring it.`);
continue;
}
const e = entry as Record<string, JsonValue>;
const path = typeof e.path === "string" ? e.path.trim() : "";
if (!path) {
warnings.push(`Manifest "review.path_instructions[${index}].path" must be a non-empty string; ignoring the entry.`);
continue;
}
if (e.instructions === undefined || e.instructions === null) {
warnings.push(`Manifest "review.path_instructions[${index}].instructions" is required; ignoring the entry.`);
continue;
}
const instructions = parsePublicSafeText(e.instructions, `review.path_instructions[${index}].instructions`, warnings);
if (instructions === null) continue; // non-string / empty / not-public-safe → already warned
out.push({ path, instructions });
}
return out;
}

/** Parse `review.profile` — one of chill / balanced / assertive (case-insensitive). `balanced` normalizes to
Expand All @@ -532,10 +586,33 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue
if (review.footerText !== null) out.footer = { text: review.footerText };
if (review.note !== null) out.note = review.note;
if (review.profile !== null) out.profile = review.profile;
if (review.pathInstructions.length > 0) out.path_instructions = review.pathInstructions.map((entry) => ({ path: entry.path, instructions: entry.instructions }));
if (Object.keys(review.fields).length > 0) out.fields = { ...review.fields } as Record<string, JsonValue>;
return out;
}

/**
* Resolve the `review.path_instructions` that APPLY to a PR — those whose glob matches at least one changed path
* — into a single prompt section for the AI reviewer, or "" when none match (so the prompt stays byte-identical).
* Pure; uses the same manifest path-glob semantics (`matchesManifestPath`) as the rest of the manifest. Capped to
* keep the prompt bounded. (#review-path-instructions)
*/
export function resolveReviewPathInstructions(pathInstructions: ReviewPathInstruction[], changedPaths: string[]): string {
if (pathInstructions.length === 0 || changedPaths.length === 0) return "";
const applicable = pathInstructions.filter((entry) => changedPaths.some((path) => matchesManifestPath(path, entry.path)));
if (applicable.length === 0) return "";
const lines = applicable.map((entry) => `- \`${entry.path}\`: ${entry.instructions}`);
return `\n\nPath-specific review instructions from the maintainer — apply these to the changed files that match each glob:\n${lines.join("\n")}`;
}

/** Resolve the AI-reviewer prompt overrides (`review.profile` + `review.path_instructions`) from a possibly-null
* manifest (null = load failure). A null manifest yields the byte-identical defaults. Centralized so the AI-review
* caller threads both in one place with the null-manifest branch covered here (unit-tested) rather than inline in
* the processor. (#review-profile / #review-path-instructions) */
export function resolveReviewPromptOverrides(manifest: FocusManifest | null): { profile: ReviewProfile | null; pathInstructions: ReviewPathInstruction[] } {
return { profile: manifest?.review.profile ?? null, pathInstructions: manifest?.review.pathInstructions ?? [] };
}

/**
* Resolve the EFFECTIVE repository settings a webhook should act on: `.gittensory.yml` > DB settings >
* safe defaults. The generic `settings:` override applies first; the friendly `gate:` alias then wins
Expand Down
14 changes: 14 additions & 0 deletions test/unit/ai-review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,20 @@ describe("review.profile shapes the reviewer system prompt (#review-profile)", (
expect(without).not.toMatch(/CHILL|ASSERTIVE/);
expect(withNull).toBe(without);
});

it("pathGuidance is appended to the system prompt; empty/absent leaves it byte-identical (#review-path-instructions)", async () => {
const systemPromptOf = (run: ReturnType<typeof vi.fn>): string => ((run.mock.calls[0]?.[1] as { messages?: Array<{ content?: string }> })?.messages?.[0]?.content ?? "");
const runGuidance = async (pathGuidance: string | undefined) => {
const run = vi.fn(async () => ({ response: reviewJson() }));
const env = createTestEnv({ AI: { run } as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI_DAILY_NEURON_BUDGET: "100000" });
await runGittensoryAiReview(env, { ...baseInput, pathGuidance });
return systemPromptOf(run);
};
expect(await runGuidance("\n\nPath-specific review instructions:\n- `src/**`: Enforce null checks.")).toContain("Enforce null checks.");
// Absent or whitespace-only → no append.
expect(await runGuidance(undefined)).not.toContain("Path-specific review instructions");
expect(await runGuidance(" ")).not.toContain("Path-specific review instructions");
});
});

describe("runGittensoryAiReview block mode (consensus)", () => {
Expand Down
Loading
Loading