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
12 changes: 11 additions & 1 deletion 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 } from "../signals/focus-manifest";
import { buildFocusManifestGuidance, 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 @@ -2022,6 +2022,10 @@ export async function runAiReviewForAdvisory(
// review + grounding + RAG use these instead of re-reading the stored rows — so a review that fired before
// detail-sync still sees the REAL diff (FIX B). Omitted (e.g. unit tests) → fall back to the stored read.
files?: Awaited<ReturnType<typeof listPullRequestFiles>> | undefined;
// `.gittensory.yml` review.profile (#review-profile), resolved by the caller from the (already-cached)
// 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;
},
): Promise<{ notes: string; reviewerCount: number } | undefined> {
const packAllowsAnyAuthorBlockingReview = args.settings.gatePack === "oss-anti-slop" && args.settings.aiReviewMode === "block";
Expand Down Expand Up @@ -2087,6 +2091,7 @@ export async function runAiReviewForAdvisory(
providerKey,
grounding,
ragContext,
profile: args.reviewProfile ?? null,
});
if (result.status !== "ok") return undefined;
if (result.consensusDefect) {
Expand Down Expand Up @@ -2515,6 +2520,10 @@ 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;
aiReview = await runAiReviewForAdvisory(env, {
settings,
advisory,
Expand All @@ -2523,6 +2532,7 @@ async function maybePublishPrPublicSurface(
author,
confirmedContributor,
files: await getReviewFiles(),
reviewProfile,
});
}

Expand Down
25 changes: 22 additions & 3 deletions src/services/ai-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { countByokAiEventsForRepoSince, recordAiUsageEvent, sumAiEstimatedNeuron
import { sanitizePublicComment } from "../queue-intelligence";
import { defangReviewInput, isSafetyEnabled } from "../review/safety";
import { isConvergenceRepoAllowed } from "../review/cutover-gate";
import type { ReviewProfile } from "../signals/focus-manifest";

/**
* The best free Workers-AI model pair for review accuracy — two different families for independence,
Expand Down Expand Up @@ -88,6 +89,13 @@ export type GittensoryAiReviewInput = {
* to today — no section is appended.
*/
ragContext?: string | null | undefined;
/**
* `.gittensory.yml` `review.profile` (#review-profile): adjusts how nitpicky the maintainer review write-up is.
* `chill` → surface only blocking defects; `assertive` → also raise minor improvements & nits; absent/`balanced`
* → the reviewer prompt is byte-identical to today. PRESENTATION ONLY — it never changes the gate verdict (the
* consensus-defect pass still runs the same), just how much advisory detail the prose carries.
*/
profile?: ReviewProfile | null | undefined;
};

/** A consensus critical defect, already public-safe, ready to become a gate blocker finding. */
Expand Down Expand Up @@ -254,11 +262,22 @@ function buildUserPrompt(input: GittensoryAiReviewInput): string {
return lines.join("\n");
}

// `.gittensory.yml` review.profile → an appended tone instruction (#review-profile). `balanced`/absent appends
// nothing (byte-identical). PRESENTATION ONLY: it shapes how many nits the write-up surfaces, never the verdict.
const REVIEW_PROFILE_SUFFIX: Record<"chill" | "assertive", string> = {
chill:
"\n\nReview profile: CHILL. Report ONLY blocking, must-fix defects (bugs, security, data loss, breaking changes). Do NOT raise style preferences, naming, or minor nitpicks — omit them entirely.",
assertive:
"\n\nReview profile: ASSERTIVE. Beyond blocking defects, also surface minor improvements, style/consistency suggestions, and nitpicks — be thorough and exacting, clearly marking each non-blocking item as a nit.",
};

/** The effective reviewer SYSTEM prompt. Appends the grounding-discipline suffix when the caller supplied one
* (flag GITTENSORY_REVIEW_GROUNDING on); absent/empty (default) → the base prompt, byte-identical to today. */
* (flag GITTENSORY_REVIEW_GROUNDING on), then the `review.profile` tone suffix when set; both absent (default)
* → the base prompt, byte-identical to today. */
function buildSystemPrompt(input: GittensoryAiReviewInput): string {
const suffix = input.grounding?.systemSuffix;
return suffix ? `${REVIEW_SYSTEM_PROMPT}${suffix}` : REVIEW_SYSTEM_PROMPT;
const groundingSuffix = input.grounding?.systemSuffix ?? "";
const profileSuffix = input.profile === "chill" || input.profile === "assertive" ? REVIEW_PROFILE_SUFFIX[input.profile] : "";
return `${REVIEW_SYSTEM_PROMPT}${groundingSuffix}${profileSuffix}`;
}

/** One Workers-AI opinion with a per-slot reliable fallback and a 3× retry on the primary. */
Expand Down
34 changes: 30 additions & 4 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ export type FocusManifestSettings = Partial<
export const REVIEW_FIELD_KEYS = ["linkedIssue", "relatedWork", "reviewLoad", "validationEvidence", "openPrQueue", "contributorContext", "gateResult"] as const;
export type ReviewFieldKey = (typeof REVIEW_FIELD_KEYS)[number];

// `review.profile` (#review-profile): how nitpicky the AI maintainer review is. `chill` = surface only blocking
// defects (bugs/security/breakage), suppress style nits; `assertive` = also raise minor improvements & nits;
// `balanced` (default / absent) leaves the reviewer prompt byte-identical. A presentation knob only — it NEVER
// changes the gate verdict, only how much advisory detail the review write-up carries.
export const REVIEW_PROFILES = ["chill", "balanced", "assertive"] as const;
export type ReviewProfile = (typeof REVIEW_PROFILES)[number];

/**
* Maintainer overrides for the public review-panel CONTENT, declared under `review:`. Customizes the
* panel without changing what gittensory measures: a custom public-safe footer lead line, a custom intro
Expand All @@ -92,6 +99,8 @@ export type FocusManifestReviewConfig = {
footerText: string | null;
note: string | null;
fields: Partial<Record<ReviewFieldKey, boolean>>;
/** `review.profile`: chill / balanced / assertive. null (absent) = balanced = byte-identical reviewer prompt. */
profile: ReviewProfile | null;
};

/**
Expand Down Expand Up @@ -186,7 +195,7 @@ const EMPTY_MANIFEST: FocusManifest = {
publicNotes: [],
gate: { ...EMPTY_GATE_CONFIG },
settings: {},
review: { present: false, footerText: null, note: null, fields: {} },
review: { present: false, footerText: null, note: null, fields: {}, profile: null },
warnings: [],
};

Expand All @@ -199,7 +208,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: {} } };
return { ...EMPTY_MANIFEST, source, warnings, gate: { ...EMPTY_GATE_CONFIG }, settings: {}, review: { present: false, footerText: null, note: null, fields: {}, profile: null } };
}

function normalizeStringList(value: JsonValue | undefined, field: string, warnings: string[]): string[] {
Expand Down Expand Up @@ -477,7 +486,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: {} };
const empty: FocusManifestReviewConfig = { present: false, footerText: null, note: null, fields: {}, profile: null };
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 @@ -497,7 +506,23 @@ 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);
return { present: footerText !== null || note !== null || Object.keys(fields).length > 0, footerText, note, fields };
const profile = parseReviewProfile(r.profile, warnings);
return { present: footerText !== null || note !== null || profile !== null || Object.keys(fields).length > 0, footerText, note, fields, profile };
}

/** Parse `review.profile` — one of chill / balanced / assertive (case-insensitive). `balanced` normalizes to
* null (the default, so the reviewer prompt stays byte-identical). Any other value is ignored with a warning. */
function parseReviewProfile(value: JsonValue | undefined, warnings: string[]): ReviewProfile | null {
if (value === undefined || value === null) return null;
if (typeof value !== "string") {
warnings.push(`Manifest "review.profile" must be a string (chill | balanced | assertive); ignoring it.`);
return null;
}
const normalized = value.trim().toLowerCase();
if (normalized === "balanced") return null; // default → no prompt change
if (normalized === "chill" || normalized === "assertive") return normalized;
warnings.push(`Manifest "review.profile" must be one of chill / balanced / assertive; ignoring "${value.slice(0, 32)}".`);
return null;
}

/** Serialize the review config for the cache round-trip; returns null when nothing is set. */
Expand All @@ -506,6 +531,7 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue
const out: Record<string, 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 (Object.keys(review.fields).length > 0) out.fields = { ...review.fields } as Record<string, JsonValue>;
return out;
}
Expand Down
30 changes: 30 additions & 0 deletions test/unit/ai-review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,36 @@ describe("runGittensoryAiReview advisory mode", () => {
});
});

describe("review.profile shapes the reviewer system prompt (#review-profile)", () => {
const systemPromptOf = (run: ReturnType<typeof vi.fn>): string => ((run.mock.calls[0]?.[1] as { messages?: Array<{ content?: string }> })?.messages?.[0]?.content ?? "");
const runProfile = async (profile: GittensoryAiReviewInput["profile"]) => {
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, profile });
return systemPromptOf(run);
};

it("chill appends the CHILL tone instruction (suppress nits)", async () => {
const system = await runProfile("chill");
expect(system).toContain("CHILL");
expect(system).not.toContain("ASSERTIVE");
});

it("assertive appends the ASSERTIVE tone instruction (also raise nits)", async () => {
const system = await runProfile("assertive");
expect(system).toContain("ASSERTIVE");
expect(system).not.toContain("CHILL");
});

it("absent / null profile leaves the prompt byte-identical (no profile suffix)", async () => {
const withNull = await runProfile(null);
const without = await runProfile(undefined);
expect(withNull).not.toMatch(/CHILL|ASSERTIVE/);
expect(without).not.toMatch(/CHILL|ASSERTIVE/);
expect(withNull).toBe(without);
});
});

describe("runGittensoryAiReview block mode (consensus)", () => {
function envWith(run: (model: string) => Promise<unknown>) {
return createTestEnv({ AI: { run: vi.fn(run) } as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI_DAILY_NEURON_BUDGET: "100000" });
Expand Down
23 changes: 22 additions & 1 deletion test/unit/focus-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ describe("compileFocusManifestPolicy", () => {
publicNotes: ["Keep PRs focused.", "Maximize your reward payout"],
gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, firstTimeContributorGrace: null },
settings: {},
review: { present: false, footerText: null, note: null, fields: {} },
review: { present: false, footerText: null, note: null, fields: {}, profile: null },
warnings: [],
});
expect(policy.publicSafe.entryGuidance).toContain("Keep PRs focused.");
Expand Down Expand Up @@ -1035,4 +1035,25 @@ describe("parseFocusManifest review config", () => {
expect(reparsed.review).toEqual(original.review);
expect(reviewConfigToJson(parseFocusManifest({}).review)).toBeNull();
});

it("parses review.profile (chill/assertive), normalizes balanced→null, and round-trips (#review-profile)", () => {
expect(parseFocusManifest({ review: { profile: "chill" } }).review.profile).toBe("chill");
expect(parseFocusManifest({ review: { profile: "ASSERTIVE" } }).review.profile).toBe("assertive"); // case-insensitive
// `balanced` is the default → normalizes to null, and a balanced-only block is NOT "present".
expect(parseFocusManifest({ review: { profile: "balanced" } }).review.profile).toBeNull();
expect(parseFocusManifest({ review: { profile: "balanced" } }).review.present).toBe(false);
// A profile-only manifest IS present and survives the reviewConfigToJson round-trip.
const chill = parseFocusManifest({ review: { profile: "chill" } });
expect(chill.review.present).toBe(true);
expect(parseFocusManifest({ review: reviewConfigToJson(chill.review) }).review).toEqual(chill.review);
});

it("ignores an invalid review.profile with a warning", () => {
const m = parseFocusManifest({ review: { profile: "spicy" } });
expect(m.review.profile).toBeNull();
expect(m.warnings.some((w) => /review\.profile.*chill.*balanced.*assertive/.test(w))).toBe(true);
const m2 = parseFocusManifest({ review: { profile: 42 } });
expect(m2.review.profile).toBeNull();
expect(m2.warnings.some((w) => /review\.profile.*must be a string/.test(w))).toBe(true);
});
});
2 changes: 1 addition & 1 deletion test/unit/signals-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,7 +904,7 @@ describe("signal coverage edge cases", () => {
collisions: buildCollisionReport(directRepo.fullName, [], [currentPr]),
preflight: buildPreflightResult({ repoFullName: directRepo.fullName, title: "Fix isolated issue", body: "Fixes #99", linkedIssues: [99] }, directRepo, [], [currentPr]),
settings: gateSettings,
review: { present: true, footerText: "Reviewed by the Acme maintainer bot.", note: "Run npm test before pushing.", fields: { relatedWork: false } },
review: { present: true, footerText: "Reviewed by the Acme maintainer bot.", note: "Run npm test before pushing.", fields: { relatedWork: false }, profile: null },
aiReview: { notes: "The change is focused.\n\n**Suggestions**\n- Add a test for the </details> edge case." },
});
expect(customizedComment).toContain("Reviewed by the Acme maintainer bot."); // custom footer lead
Expand Down
Loading