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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Gittensory CI and gittensory review score, gate, and comment on pull requests. T
- **CI + full-file grounding** — grounds the AI reviewer with the PR's finished CI status and the full post-change content of the changed files, so claims are verified against reality instead of predicted.
- **Codebase RAG** — retrieval-augmented context that queries the codebase vector index for related callers, modules, and conventions and appends them to the reviewer prompt (additive only; inert until an index exists).
- **Submitter-reputation gating** — an internal-only spend control that downgrades new / burst / low-reputation submitters to a deterministic-only review, never surfaced on any public comment, label, or check.
- **Unified review comment** — renders the public PR feedback as one in-place comment instead of multiple panels.
- **Unified review comment** — renders the public PR feedback as one in-place comment instead of multiple panels. With `.gittensory.yml`'s `review.changed_files_summary` also on (off by default), it gains a deterministic, no-AI "Changed files" collapsible: one row per file category (source/test/docs/config/generated), with file counts and +/- totals.
- **Per-repo activation** — capabilities roll forward (and back) one flag and one repo at a time via the `GITTENSORY_REVIEW_REPOS` allowlist.

**Check-run and comment surfaces, disambiguated** (a common point of confusion — these are three independent, separately-configured things, not layers of the same feature):
Expand Down
17 changes: 17 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7639,6 +7639,7 @@ async function maybePublishPrPublicSurface(
| undefined;
let inlineCommentsEnabledForReview = false;
let suggestionsEnabledForReview = false;
let changedFilesSummaryEnabledForReview = false;
let aiReviewExpected = false;
let aiReviewWasReused = false;
let gateFinalized = false;
Expand Down Expand Up @@ -8104,6 +8105,13 @@ async function maybePublishPrPublicSurface(
deliveryId: webhook.deliveryId,
headSha: advisory.headSha ?? null,
}));
// review.changed_files_summary (#1957): deterministic, no-AI — resolve it here, UNCONDITIONALLY, rather than
// inside the aiReviewWillRun-gated closure below. This table must still render whenever the manifest opts
// in even when the AI review itself is skipped this pass (author blacklisted, frozen for manual review, or
// AI review disabled for the repo) — it has nothing to do with the AI pipeline. Captured into the
// outer-scoped `changedFilesSummaryEnabledForReview` (mirroring inlineCommentsEnabledForReview/
// suggestionsEnabledForReview) so it survives past this try block to the publish step below.
changedFilesSummaryEnabledForReview = resolveReviewPromptOverrides(reviewManifestForAutoReview).changedFilesSummary;
const aiReviewWillRun =
!authorBlacklisted &&
!isFrozenForManualReview &&
Expand Down Expand Up @@ -9294,6 +9302,15 @@ async function maybePublishPrPublicSurface(
}),
reRunLabel: `${PR_PANEL_RETRIGGER_MARKER} Re-run Gittensory review`,
...(beforeAfter.length > 0 ? { beforeAfter } : {}),
...(changedFilesSummaryEnabledForReview
? {
changedFilesSummary: unifiedFiles.map((file) => ({
path: file.path,
additions: file.additions,
deletions: file.deletions,
})),
}
: {}),
});
} else {
deterministicBody = buildPublicPrIntelligenceComment(commentArgs);
Expand Down
67 changes: 66 additions & 1 deletion src/review/unified-comment-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { CaptureRoute } from "./visual/capture";
// verbatim or `createOrUpdatePrIntelligenceComment` posts a DUPLICATE instead of updating in place.
import { PR_PANEL_COMMENT_MARKER } from "../github/comments";
import { GITTENSORY_GATE_CHECK_NAME } from "./check-names";
import { classifyChangedFile, type ReviewFileClass } from "./changed-files-classify";
import {
buildUnifiedReviewInput,
renderUnifiedReviewComment,
Expand Down Expand Up @@ -306,6 +307,12 @@ export type UnifiedCommentBridgeArgs = {
* Public-safe: only URLs + route paths — no private terms. Default OFF (the processor passes this only
* when screenshotsAllowed + the PR touches web-visible files). */
beforeAfter?: CaptureRoute[] | undefined;
/** Changed-file path + additions/deletions, one entry per file (review.changed_files_summary port). When
* present + non-empty, a "Changed files" collapsible (one row per source/test/docs/config/generated
* category, with file counts and +/- totals) is appended. Deterministic, no AI. Default OFF (the processor
* passes this only when the manifest opts in — see `resolveReviewPromptOverrides`'s `changedFilesSummary`).
* (#1957) */
changedFilesSummary?: ChangedFileSummaryInput[] | undefined;
/** The disposition holds this PR for owner review because its diff touches a hard-guardrail path — so an
* otherwise-ready comment renders "held for review" instead of "safe to merge". (#guarded-hold-comment) */
heldForReview?: boolean | undefined;
Expand Down Expand Up @@ -361,6 +368,55 @@ export function buildBeforeAfterCollapsible(routes: CaptureRoute[]): UnifiedColl
return { title: "Visual preview", body, rawHtml: true };
}

/** A changed file's path + line deltas — everything `buildChangedFilesSummaryCollapsible` needs to group and
* total. Deliberately narrower than `PullRequestFileRecord` (path/additions/deletions only) so the bridge
* doesn't drag GitHub's full file-record shape into its pure-rendering surface. */
export type ChangedFileSummaryInput = { path: string; additions: number; deletions: number };

/** Display order for the "Changed files" table — SOURCE FIRST, mirroring the same source-first priority this
* codebase already applies to the AI reviewer's own diff ordering (`diffFilePriority`,
* `src/review/review-diff.ts`): the code a maintainer most needs to read leads, generated/mechanical output
* trails. A category absent from the PR's changed files is simply omitted (no zero rows). */
const CHANGED_FILE_CATEGORY_ORDER: ReviewFileClass[] = ["source", "test", "docs", "config", "generated"];

const CHANGED_FILE_CATEGORY_LABEL: Record<ReviewFileClass, string> = {
source: "Source",
test: "Test",
docs: "Docs",
config: "Config",
generated: "Generated",
};

/**
* Build the "Changed files" collapsible: one row per file category (source/test/docs/config/generated, via
* the deterministic `classifyChangedFile`), with a file count and +/- totals — collapsing an arbitrarily large
* same-category group into a single row so a big PR doesn't turn into a wall of per-file lines. No AI, no
* network — pure grouping over data the caller already has. Returns null when there are no files (nothing to
* summarize), so the caller can unconditionally chain this alongside the other optional collapsibles.
*/
export function buildChangedFilesSummaryCollapsible(files: ChangedFileSummaryInput[]): UnifiedCollapsible | null {
if (files.length === 0) return null;
const totals = new Map<ReviewFileClass, { count: number; additions: number; deletions: number }>();
for (const file of files) {
const category = classifyChangedFile(file.path);
const entry = totals.get(category);
if (entry) {
entry.count += 1;
entry.additions += file.additions;
entry.deletions += file.deletions;
} else {
totals.set(category, { count: 1, additions: file.additions, deletions: file.deletions });
}
}
const rows = CHANGED_FILE_CATEGORY_ORDER.flatMap((category) => {
const entry = totals.get(category);
if (!entry) return [];
return [`| ${CHANGED_FILE_CATEGORY_LABEL[category]} | ${entry.count} | +${entry.additions} | -${entry.deletions} |`];
});
const body = ["| Category | Files | Added | Removed |", "| --- | --- | --- | --- |", ...rows].join("\n");
return { title: "Changed files", body };
}

/**
* Build the unified PR-review comment body from gittensory's live data. Returns a string that STARTS with
* the panel marker (so the existing upsert updates in place) followed by the rendered unified comment.
Expand Down Expand Up @@ -414,11 +470,20 @@ export function buildUnifiedCommentBody(args: UnifiedCommentBridgeArgs): string
const visibleRows = args.panelRows.filter((row) => args.reviewFields?.[row.key] !== false);
const signals = panelRowsToSignalRows(visibleRows);

// review.changed_files_summary port: when the manifest opts in, the processor hands us every changed file's
// path + deltas here; append the grouped "Changed files" collapsible ahead of the visual preview (structure
// before pixels). Flag-OFF (the processor passes undefined) ⇒ extraCollapsibles is unchanged. (#1957)
const changedFilesCollapsible =
args.changedFilesSummary && args.changedFilesSummary.length > 0
? buildChangedFilesSummaryCollapsible(args.changedFilesSummary)
: null;
const withChangedFiles =
changedFilesCollapsible !== null ? [...(args.extraCollapsibles ?? []), changedFilesCollapsible] : args.extraCollapsibles;
// Visual-capture port: when before/after routes are present, append a "Visual preview" collapsible to the
// extra sections. Flag-OFF (the processor passes no beforeAfter) ⇒ extraCollapsibles is unchanged.
const visualCollapsible = args.beforeAfter && args.beforeAfter.length > 0 ? buildBeforeAfterCollapsible(args.beforeAfter) : null;
const extraCollapsibles =
visualCollapsible !== null ? [...(args.extraCollapsibles ?? []), visualCollapsible] : args.extraCollapsibles;
visualCollapsible !== null ? [...(withChangedFiles ?? []), visualCollapsible] : withChangedFiles;

const body = renderUnifiedReviewComment(input, {
brand: args.brand ?? "Gittensory review",
Expand Down
22 changes: 17 additions & 5 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,12 @@ export type FocusManifestReviewConfig = {
* otherwise) — this is an ADDITIONAL opt-in on top of `review.inline_comments`, not a replacement gate.
* null/false (default, absent) = no suggestion blocks = byte-identical behavior. (#1956) */
suggestions: boolean | null;
/** `review.changed_files_summary`: when true, the unified review comment (only rendered at all when the
* `unifiedComment` convergence feature is on) gains a deterministic, no-AI "Changed files" collapsible: one
* row per file category (source/test/docs/config/generated), with file counts and +/- totals, via the
* existing `classifyChangedFile` classifier (`src/review/changed-files-classify.ts`, built for this table
* under #2143). null/false (default, absent) = no changed-files section = byte-identical behavior. (#1957) */
changedFilesSummary: boolean | 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[];
Expand Down Expand Up @@ -574,7 +580,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, suggestions: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG } },
review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG } },
features: { ...EMPTY_FEATURES_CONFIG },
contentLane: { ...EMPTY_CONTENT_LANE_CONFIG },
repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG },
Expand Down Expand Up @@ -604,7 +610,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, suggestions: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG } },
review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG } },
features: { ...EMPTY_FEATURES_CONFIG },
contentLane: { ...EMPTY_CONTENT_LANE_CONFIG },
repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG },
Expand Down Expand Up @@ -1535,7 +1541,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, suggestions: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG } };
const empty: FocusManifestReviewConfig = { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG } };
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 @@ -1573,6 +1579,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 suggestions = normalizeOptionalBoolean(r.suggestions, "review.suggestions", warnings);
const changedFilesSummary = normalizeOptionalBoolean(r.changed_files_summary, "review.changed_files_summary", warnings);
const pathInstructions = parseReviewPathInstructions(r.path_instructions, warnings);
const instructions = parsePublicSafeText(r.instructions, "review.instructions", warnings);
const excludePaths = parseReviewExcludePaths(r.exclude_paths, warnings);
Expand All @@ -1590,6 +1597,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo
securityFocus !== null ||
inlineComments !== null ||
suggestions !== null ||
changedFilesSummary !== null ||
pathInstructions.length > 0 ||
instructions !== null ||
excludePaths.length > 0 ||
Expand All @@ -1611,6 +1619,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo
securityFocus,
inlineComments,
suggestions,
changedFilesSummary,
pathInstructions,
instructions,
excludePaths,
Expand Down Expand Up @@ -1929,6 +1938,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.suggestions !== null) out.suggestions = review.suggestions;
if (review.changedFilesSummary !== null) out.changed_files_summary = review.changedFilesSummary;
if (review.instructions !== null) out.instructions = review.instructions;
if (review.pathInstructions.length > 0) out.path_instructions = review.pathInstructions.map((entry) => ({ path: entry.path, instructions: entry.instructions }));
if (review.excludePaths.length > 0) out.exclude_paths = [...review.excludePaths];
Expand Down Expand Up @@ -2067,13 +2077,15 @@ export function composeManifestReviewInstructions(instructions: string | null, t
* failure). A null manifest yields the byte-identical defaults. Centralized so the AI-review caller threads them
* in one place with the null-manifest branch covered here (unit-tested) rather than inline in the processor.
* (#review-profile / #review-tone / #review-security-focus / #review-path-instructions / #review-exclude-paths / #2043 / #selfhost-ai-model-override / #1956) */
export function resolveReviewPromptOverrides(manifest: FocusManifest | null): { profile: ReviewProfile | null; tone: string | null; securityFocus: boolean; inlineComments: boolean; suggestions: boolean; pathInstructions: ReviewPathInstruction[]; instructions: string | null; excludePaths: string[]; pathFilters: string[]; selfHostAiModel: SelfHostAiModelConfig } {
export function resolveReviewPromptOverrides(manifest: FocusManifest | null): { profile: ReviewProfile | null; tone: string | null; securityFocus: boolean; inlineComments: boolean; suggestions: boolean; changedFilesSummary: boolean; pathInstructions: ReviewPathInstruction[]; instructions: string | null; excludePaths: string[]; pathFilters: string[]; selfHostAiModel: SelfHostAiModelConfig } {
// inlineComments resolves to a strict boolean — true ONLY when the manifest explicitly set review.inline_comments:
// true; null/false/absent ⇒ false. The caller ANDs this per-repo toggle with the operator flag + cutover allowlist.
// securityFocus resolves the same way — true ONLY when the manifest explicitly set review.security_focus: true.
// suggestions resolves the same way (#1956) — the caller further ANDs it with the already-resolved
// inlineComments gate, since a suggestion has nothing to attach to without an inline comment.
return { profile: manifest?.review.profile ?? null, tone: manifest?.review.tone ?? null, securityFocus: manifest?.review.securityFocus === true, inlineComments: manifest?.review.inlineComments === true, suggestions: manifest?.review.suggestions === true, pathInstructions: manifest?.review.pathInstructions ?? [], instructions: manifest?.review.instructions ?? null, excludePaths: manifest?.review.excludePaths ?? [], pathFilters: manifest?.review.pathFilters ?? [], selfHostAiModel: resolveReviewSelfHostAiModel(manifest) };
// changedFilesSummary resolves the same way (#1957) — independent of inlineComments/suggestions; it only
// needs the unified-comment convergence feature itself to be on (the caller's own outer gate).
return { profile: manifest?.review.profile ?? null, tone: manifest?.review.tone ?? null, securityFocus: manifest?.review.securityFocus === true, inlineComments: manifest?.review.inlineComments === true, suggestions: manifest?.review.suggestions === true, changedFilesSummary: manifest?.review.changedFilesSummary === true, pathInstructions: manifest?.review.pathInstructions ?? [], instructions: manifest?.review.instructions ?? null, excludePaths: manifest?.review.excludePaths ?? [], pathFilters: manifest?.review.pathFilters ?? [], selfHostAiModel: resolveReviewSelfHostAiModel(manifest) };
}

/** Resolve `review.pre_merge_checks` from a possibly-null manifest (null = load failure ⇒ no checks). Centralized
Expand Down
Loading
Loading