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
5 changes: 5 additions & 0 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,11 @@ review:
# Requires operator flag GITTENSORY_REVIEW_FIX_HANDOFF + cutover allowlist AND this toggle.
# fixHandoff: false

# Read-only auto-merge readiness summary (#2051). Bool | null. Default: null/false — byte-identical.
# When true, the unified comment gains a collapsible showing which auto-merge conditions currently pass/fail
# (CI green, gate passing, mergeable-clean, valid linked issue). SURFACE ONLY — never changes the decision.
# auto_merge_summary: false

# Boundary-safe test generation (#2189). Bool | null. Default: null/false — byte-identical.
# Requires operator flag GITTENSORY_REVIEW_TEST_GENERATION AND this toggle.
# test_generation: false
Expand Down
5 changes: 5 additions & 0 deletions config/examples/gittensory.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,11 @@ review:
# Requires operator flag GITTENSORY_REVIEW_FIX_HANDOFF + cutover allowlist AND this toggle.
# fixHandoff: false

# Read-only auto-merge readiness summary (#2051). Bool | null. Default: null/false — byte-identical.
# When true, the unified comment gains a collapsible showing which auto-merge conditions currently pass/fail
# (CI green, gate passing, mergeable-clean, valid linked issue). SURFACE ONLY — never changes the decision.
# auto_merge_summary: false

# Boundary-safe test generation (#2189). Bool | null. Default: null/false — byte-identical.
# Requires operator flag GITTENSORY_REVIEW_TEST_GENERATION AND this toggle.
# test_generation: false
Expand Down
36 changes: 36 additions & 0 deletions src/review/unified-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,42 @@ export interface UnifiedCollapsible {
rawHtml?: boolean;
}

/** Already-computed auto-merge readiness facts (#2051). The host resolves each from signals it ALREADY has — the
* merge-readiness probe, the gate verdict, the linked-issue check — and injects them here. This module only
* RENDERS them into a read-only table; it never re-derives a condition or calls any merge/close decision path. */
export interface AutoMergeSummarySignals {
/** Every required CI check is green. */
ciGreen: boolean;
/** The Gittensory gate is passing (no hard blocker). */
gatePassing: boolean;
/** GitHub reports the branch mergeable / clean (no conflict, not behind). */
mergeableClean: boolean;
/** The PR references a valid, open linked issue. */
linkedIssueValid: boolean;
}

/** Build the READ-ONLY "auto-merge readiness" collapsible (#2051) — a conditions table showing which auto-merge
* conditions currently pass/fail, rendered purely from the injected {@link AutoMergeSummarySignals}. Informational
* only: it states the current condition states, never a decision or a promise to merge. Pure — no IO, no decision
* path. The caller renders this ONLY when `review.auto_merge_summary` is on, so off ⇒ nothing added ⇒ byte-identical. */
export function buildAutoMergeSummaryCollapsible(signals: AutoMergeSummarySignals): UnifiedCollapsible {
const mark = (ok: boolean): string => (ok ? "✅" : "❌");
const rows: Array<[string, boolean]> = [
["CI checks green", signals.ciGreen],
["Gate passing", signals.gatePassing],
["Branch mergeable (clean)", signals.mergeableClean],
["Valid linked issue", signals.linkedIssueValid],
];
const body = [
"_Read-only snapshot of the current auto-merge conditions — informational; it does not decide or trigger a merge._",
"",
"| Condition | Status |",
"| --- | --- |",
...rows.map(([label, ok]) => `| ${label} | ${mark(ok)} |`),
].join("\n");
return { title: "Auto-merge readiness (read-only)", body };
}

/** The host (gittensory) side: brand, readiness score, signals, sections, re-run, footer. */
export interface UnifiedCommentContext {
/** Headline brand, default "Gittensory review". */
Expand Down
15 changes: 12 additions & 3 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,11 @@ export type FocusManifestReviewConfig = {
* false (default, absent) = no fix-handoff blocks = byte-identical. Operator-gated too (GITTENSORY_REVIEW_FIX_HANDOFF
* + the convergence cutover allowlist) — the manifest toggle is only one of the ANDed gates. (#2176, for #1962) */
fixHandoff: boolean | null;
/** `review.auto_merge_summary`: when true, the unified comment gains a READ-ONLY collapsible showing which
* auto-merge conditions currently pass/fail (CI green, gate passing, mergeable-clean, valid linked issue),
* rendered from already-computed readiness signals. SURFACE ONLY — never changes the merge/close decision.
* null/false (default, absent) = no summary = byte-identical. (#2051, for #1959) */
autoMergeSummary: boolean | null;
/** `review.suggestions`: when true, an inline finding whose AI-provided fix is precise enough to anchor to a
* single line is ALSO rendered as a GitHub-native ` ```suggestion ` block a contributor can commit in one
* click. Only takes effect when inline comments are already on (a suggestion has nothing to attach to
Expand Down Expand Up @@ -777,7 +782,7 @@ const EMPTY_MANIFEST: FocusManifest = {
publicNotes: [],
gate: { ...EMPTY_GATE_CONFIG },
settings: {},
review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, suggestions: null, changedFilesSummary: null, effortScore: null, testGeneration: null, impactMap: null, cultureProfile: null, reviewMemory: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null },
review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, testGeneration: null, impactMap: null, cultureProfile: null, reviewMemory: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null },
features: { ...EMPTY_FEATURES_CONFIG },
contentLane: { ...EMPTY_CONTENT_LANE_CONFIG },
repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG },
Expand Down Expand Up @@ -808,7 +813,7 @@ function emptyManifest(source: FocusManifestSource, warnings: string[] = []): Fo
warnings,
gate: { ...EMPTY_GATE_CONFIG },
settings: {},
review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, suggestions: null, changedFilesSummary: null, effortScore: null, testGeneration: null, impactMap: null, cultureProfile: null, reviewMemory: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null },
review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, testGeneration: null, impactMap: null, cultureProfile: null, reviewMemory: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null },
features: { ...EMPTY_FEATURES_CONFIG },
contentLane: { ...EMPTY_CONTENT_LANE_CONFIG },
repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG },
Expand Down Expand Up @@ -1783,7 +1788,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: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, suggestions: null, changedFilesSummary: null, effortScore: null, testGeneration: null, impactMap: null, cultureProfile: null, reviewMemory: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null };
const empty: FocusManifestReviewConfig = { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, testGeneration: null, impactMap: null, cultureProfile: null, reviewMemory: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: 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 Down Expand Up @@ -1821,6 +1826,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo
const securityFocus = normalizeOptionalBoolean(r.security_focus, "review.security_focus", warnings);
const inlineComments = normalizeOptionalBoolean(r.inline_comments, "review.inline_comments", warnings);
const fixHandoff = normalizeOptionalBoolean(r.fixHandoff, "review.fixHandoff", warnings);
const autoMergeSummary = normalizeOptionalBoolean(r.auto_merge_summary, "review.auto_merge_summary", warnings);
const suggestions = normalizeOptionalBoolean(r.suggestions, "review.suggestions", warnings);
const changedFilesSummary = normalizeOptionalBoolean(r.changed_files_summary, "review.changed_files_summary", warnings);
const effortScore = normalizeOptionalBoolean(r.effort_score, "review.effort_score", warnings);
Expand Down Expand Up @@ -1856,6 +1862,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo
securityFocus !== null ||
inlineComments !== null ||
fixHandoff !== null ||
autoMergeSummary !== null ||
suggestions !== null ||
changedFilesSummary !== null ||
effortScore !== null ||
Expand Down Expand Up @@ -1893,6 +1900,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo
securityFocus,
inlineComments,
fixHandoff,
autoMergeSummary,
suggestions,
changedFilesSummary,
effortScore,
Expand Down Expand Up @@ -2357,6 +2365,7 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue
if (review.securityFocus !== null) out.security_focus = review.securityFocus;
if (review.inlineComments !== null) out.inline_comments = review.inlineComments;
if (review.fixHandoff !== null) out.fixHandoff = review.fixHandoff;
if (review.autoMergeSummary !== null) out.auto_merge_summary = review.autoMergeSummary;
if (review.suggestions !== null) out.suggestions = review.suggestions;
if (review.changedFilesSummary !== null) out.changed_files_summary = review.changedFilesSummary;
if (review.effortScore !== null) out.effort_score = review.effortScore;
Expand Down
3 changes: 2 additions & 1 deletion test/unit/focus-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => {
securityFocus: "security_focus:",
inlineComments: "inline_comments:",
fixHandoff: "fixHandoff:",
autoMergeSummary: "auto_merge_summary:",
suggestions: "suggestions:",
changedFilesSummary: "changed_files_summary:",
effortScore: "effort_score:",
Expand Down Expand Up @@ -788,7 +789,7 @@ describe("compileFocusManifestPolicy", () => {
publicNotes: ["Keep PRs focused.", "Maximize your reward payout"],
gate: { present: false, enabled: null, checkMode: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null },
settings: {},
review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, suggestions: null, changedFilesSummary: null, effortScore: null, testGeneration: null, impactMap: null, cultureProfile: null, reviewMemory: null, findingCategories: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null },
review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, testGeneration: null, impactMap: null, cultureProfile: null, reviewMemory: null, findingCategories: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null },
features: { present: false, rag: null, reputation: null, unifiedComment: null, safety: null },
contentLane: { present: false, entryFileGlob: null, providerFileGlob: null, artifactGlob: null, collectionField: null, maxAppendedEntries: null, duplicateKeyFields: [], validatorId: null },
repoDocGeneration: { present: false, enabled: false, scope: ["agents"], allowOverwriteExisting: false, refreshIntervalDays: 7 },
Expand Down
57 changes: 57 additions & 0 deletions test/unit/review-auto-merge-summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { parseFocusManifest, reviewConfigToJson } from "../../src/signals/focus-manifest";
import { buildAutoMergeSummaryCollapsible, type AutoMergeSummarySignals } from "../../src/review/unified-comment";

const reviewOf = (autoMergeSummary: unknown) => parseFocusManifest({ review: { auto_merge_summary: autoMergeSummary } });
const allPass: AutoMergeSummarySignals = { ciGreen: true, gatePassing: true, mergeableClean: true, linkedIssueValid: true };

describe("review.auto_merge_summary config toggle (#2051)", () => {
it("absent ⇒ null and OMITTED on serialize (byte-identical)", () => {
const review = parseFocusManifest({ review: { note: "x" } }).review;
expect(review.autoMergeSummary).toBe(null);
expect("auto_merge_summary" in (reviewConfigToJson(review) as Record<string, unknown>)).toBe(false);
});

it("true / false parse, mark present, and round-trip", () => {
for (const v of [true, false]) {
const review = reviewOf(v).review;
expect(review.autoMergeSummary).toBe(v);
expect(review.present).toBe(true);
const json = reviewConfigToJson(review) as Record<string, unknown>;
expect(json.auto_merge_summary).toBe(v);
expect(parseFocusManifest({ review: json }).review.autoMergeSummary).toBe(v);
}
});

it("a non-boolean value warns and falls back to null", () => {
const m = reviewOf("maybe");
expect(m.review.autoMergeSummary).toBe(null);
expect(m.warnings.some((w) => /review\.auto_merge_summary/.test(w))).toBe(true);
});
});

describe("buildAutoMergeSummaryCollapsible read-only render (#2051)", () => {
it("renders a 4-condition table from injected signals — all passing", () => {
const c = buildAutoMergeSummaryCollapsible(allPass);
expect(c.title).toMatch(/read-only/i);
for (const label of ["CI checks green", "Gate passing", "Branch mergeable (clean)", "Valid linked issue"]) {
expect(c.body).toContain(label);
}
expect(c.body).not.toContain("❌"); // all four pass
expect((c.body.match(/✅/g) ?? []).length).toBe(4);
});

it("reflects EXACTLY the injected signal states (❌ per failing condition), never re-deriving", () => {
const mixed: AutoMergeSummarySignals = { ciGreen: true, gatePassing: false, mergeableClean: true, linkedIssueValid: false };
const c = buildAutoMergeSummaryCollapsible(mixed);
expect((c.body.match(/✅/g) ?? []).length).toBe(2);
expect((c.body.match(/❌/g) ?? []).length).toBe(2);
// the failing rows are the two false signals, in order
expect(c.body).toMatch(/Gate passing \| ❌/);
expect(c.body).toMatch(/Valid linked issue \| ❌/);
// read-only framing — never a merge promise/action verb
expect(c.body).toMatch(/does not decide or trigger a merge/i);
// deterministic: same input ⇒ identical output
expect(buildAutoMergeSummaryCollapsible(mixed)).toEqual(c);
});
});
Loading