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
19 changes: 19 additions & 0 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,25 @@ gate:
# ----------------------------------------------------------------------------
# Everything a maintainer can toggle in the dashboard can be set here as code.
# All values shown are the safe defaults; delete any line to inherit it.
#
# Review output controls. These tune review output without changing the
# deterministic gate policy above. Omit the block to keep the byte-identical
# defaults.
review:
# Skip AI/public review output for matching PR author logins. Useful for dependency
# bump or release automation that already has separate policy/CI. This is a quiet
# skip, not a gate failure: when the Orb review check is enabled it is completed as
# "skipped" with an ignored-author reason.
#
# Glob list. `*` and `**` both match any run of characters; `**/name` also matches
# `name` at the root. Matching is case-insensitive against the GitHub login.
# Default: [] (every author remains review-eligible).
auto_review:
ignore_authors:
- "*[bot]"
- dependabot
- renovate

settings:
# Who receives the public PR comment.
# off | detected_contributors_only | all_prs. Default: detected_contributors_only.
Expand Down
3 changes: 3 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10177,6 +10177,7 @@
"surface_off",
"missing_author",
"bot_author",
"ignored_author",
"maintainer_author",
"miner_detection_unavailable",
"not_official_gittensor_miner",
Expand Down Expand Up @@ -10471,6 +10472,7 @@
"surface_off",
"missing_author",
"bot_author",
"ignored_author",
"maintainer_author",
"miner_detection_unavailable",
"not_official_gittensor_miner",
Expand Down Expand Up @@ -15730,6 +15732,7 @@
"surface_off",
"missing_author",
"bot_author",
"ignored_author",
"maintainer_author",
"miner_detection_unavailable",
"not_official_gittensor_miner"
Expand Down
3 changes: 3 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ const PR_VISIBILITY_SKIP_REASONS = [
"surface_off",
"missing_author",
"bot_author",
"ignored_author",
"maintainer_author",
"miner_detection_unavailable",
"not_official_gittensor_miner",
Expand Down Expand Up @@ -5365,6 +5366,8 @@ function skippedPrAuditRemediation(reason: string): string {
return "Retry after GitHub provides a resolvable pull request author.";
case "bot_author":
return "No action needed; bot-authored pull requests are intentionally kept quiet.";
case "ignored_author":
return "No action needed; the repository manifest explicitly skips review output for this author.";
case "maintainer_author":
return "Enable maintainer-authored PRs in repository settings only if those PRs should receive public GitHub App output.";
case "miner_detection_unavailable":
Expand Down
4 changes: 2 additions & 2 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,7 @@ export const RepoSettingsPreviewSchema = z
willLabel: z.boolean(),
willCheckRun: z.boolean(),
skipped: z.boolean(),
skipReason: z.enum(["surface_off", "missing_author", "bot_author", "maintainer_author", "miner_detection_unavailable", "not_official_gittensor_miner"]).nullable(),
skipReason: z.enum(["surface_off", "missing_author", "bot_author", "ignored_author", "maintainer_author", "miner_detection_unavailable", "not_official_gittensor_miner"]).nullable(),
actions: z.array(z.enum(["skip", "comment", "label", "check_run", "none"])),
summary: z.string(),
}),
Expand Down Expand Up @@ -864,7 +864,7 @@ export const SkippedPrAuditExportSchema = z
filters: z.object({
repoFullName: z.string().nullable(),
reason: z
.enum(["surface_off", "missing_author", "bot_author", "maintainer_author", "miner_detection_unavailable", "not_official_gittensor_miner"])
.enum(["surface_off", "missing_author", "bot_author", "ignored_author", "maintainer_author", "miner_detection_unavailable", "not_official_gittensor_miner"])
.nullable(),
since: z.string().nullable(),
}),
Expand Down
2 changes: 1 addition & 1 deletion src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,7 @@ export function buildOpenApiSpec() {
param: { description: "Optional repository filter. Browser sessions must have control-panel access to this repo." },
example: "JSONbored/gittensory",
}),
reason: z.enum(["surface_off", "missing_author", "bot_author", "maintainer_author", "miner_detection_unavailable", "not_official_gittensor_miner"]).optional().openapi({
reason: z.enum(["surface_off", "missing_author", "bot_author", "ignored_author", "maintainer_author", "miner_detection_unavailable", "not_official_gittensor_miner"]).optional().openapi({
param: { description: "Optional PR skip reason filter." },
example: "not_official_gittensor_miner",
}),
Expand Down
42 changes: 41 additions & 1 deletion src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ import {
filterReviewFilesForAi,
resolvePullRequestAutoReviewSkipReason,
resolveRepoEnrichmentToggles,
resolveReviewAutoReviewConfig,
resolveReviewPathInstructions,
resolveReviewPreMergeChecks,
resolveReviewPromptOverrides,
Expand All @@ -369,6 +370,7 @@ import {
type ReviewPathInstruction,
type ReviewProfile,
} from "../signals/focus-manifest";
import { decideReviewEligibility } from "../review/review-eligibility";
import {
loadRepoFocusManifest,
loadRepoFocusManifests,
Expand Down Expand Up @@ -578,7 +580,7 @@ function primeLiveMergeState(
// two equal sets in different orders must hit the SAME cache entry rather than needlessly duplicating fetches.
function expectedCiContextsKeyPart(expectedCiContexts: ReadonlyArray<string> | null | undefined): string {
if (!expectedCiContexts || expectedCiContexts.length === 0) return "";
return [...expectedCiContexts].sort().join("");
return [...expectedCiContexts].sort().join("\0");
}

// Stable, order-independent cache-key fragment for the RESOLVED required-contexts set (#selfhost-ci-verification):
Expand Down Expand Up @@ -7279,6 +7281,44 @@ async function maybePublishPrPublicSurface(
!needsMinerCheckForDetectedComment))
)
return undefined;
const reviewManifest = await loadRepoFocusManifest(env, repoFullName).catch(() => null);
const autoReviewConfig = resolveReviewAutoReviewConfig(reviewManifest);
const reviewEligibility = decideReviewEligibility({
authorLogin: author,
ignoreAuthors: autoReviewConfig.ignoreAuthors,
});
if (!reviewEligibility.eligible) {
await auditPrVisibilitySkip(
env,
repoFullName,
pr.number,
author,
reviewEligibility.skipReason,
webhook.deliveryId,
);
if (gateEnabled) {
const gateCheckResult = await createOrUpdateSkippedGateCheckRun(
env,
installationId,
repoFullName,
advisory,
"Review skipped: ignored author.",
mode,
);
/* v8 ignore next -- permission-missing audit behavior mirrors the existing skipped-check path above. */
if (gateCheckResult?.kind === "permission_missing") {
await auditGateCheckPermissionMissing(
env,
author,
repoFullName,
pr.number,
webhook.deliveryId,
gateCheckResult.warning,
);
}
}
return undefined;
}
// A missing author already forces publicSurfaceSkipped=true above (decidePublicSurface's own
// "missing_author" skip), so the guard just above already returns undefined whenever `!author` combines with
// `!gateEnabled && !autonomyNeedsGateEvaluation` -- a separate `!author` check here can never fire and was
Expand Down
57 changes: 57 additions & 0 deletions src/review/review-eligibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { matchesManifestPath } from "../signals/focus-manifest";

export type ReviewEligibilitySkipReason = "ignored_author";

export type ReviewEligibilityInput = {
authorLogin?: string | null | undefined;
ignoreAuthors?: readonly string[] | null | undefined;
};

export type ReviewEligibilityDecision =
| {
eligible: true;
skipReason: null;
matchedPattern: null;
}
| {
eligible: false;
skipReason: ReviewEligibilitySkipReason;
matchedPattern: string;
};

export const REVIEW_ELIGIBLE: ReviewEligibilityDecision = {
eligible: true,
skipReason: null,
matchedPattern: null,
};

function normalizeAuthorLogin(login: string | null | undefined): string {
return (login ?? "").trim();
}

/**
* Decide whether the auto-review pipeline should spend/reply for this PR author. This is intentionally narrower
* than the gate decision: ignored authors only suppress review/public output, never create a blocker.
*/
export function decideReviewEligibility(input: ReviewEligibilityInput): ReviewEligibilityDecision {
const author = normalizeAuthorLogin(input.authorLogin);
if (!author) return REVIEW_ELIGIBLE;

for (const pattern of input.ignoreAuthors ?? []) {
const trimmed = pattern.trim();
if (!trimmed) continue;
if (matchesManifestPath(author, trimmed)) {
return {
eligible: false,
skipReason: "ignored_author",
matchedPattern: trimmed,
};
}
}

return REVIEW_ELIGIBLE;
}

export function isIgnoredReviewAuthor(input: ReviewEligibilityInput): boolean {
return !decideReviewEligibility(input).eligible;
}
21 changes: 16 additions & 5 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1526,6 +1526,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo
footerText,
note,
fields,
autoReview,
enrichmentAnalyzers,
profile,
securityFocus,
Expand All @@ -1535,7 +1536,6 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo
excludePaths,
pathFilters,
preMergeChecks,
autoReview,
};
}

Expand Down Expand Up @@ -1645,11 +1645,8 @@ function parseManifestGlobList(value: JsonValue | undefined, fieldLabel: string,
return [];
}
const out: string[] = [];
const seen = new Set<string>();
for (const [index, entry] of value.entries()) {
if (out.length >= MAX_PATH_INSTRUCTIONS) {
warnings.push(`Manifest "${fieldLabel}" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`);
break;
}
const glob = typeof entry === "string" ? entry.trim() : "";
if (!glob) {
warnings.push(`Manifest "${fieldLabel}[${index}]" must be a non-empty string; ignoring it.`);
Expand All @@ -1659,6 +1656,13 @@ function parseManifestGlobList(value: JsonValue | undefined, fieldLabel: string,
warnings.push(`Manifest "${fieldLabel}[${index}]" exceeds ${MAX_ITEM_LENGTH} chars; ignoring it.`);
continue;
}
const key = glob.toLowerCase();
if (seen.has(key)) continue;
if (out.length >= MAX_PATH_INSTRUCTIONS) {
warnings.push(`Manifest "${fieldLabel}" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`);
break;
}
seen.add(key);
out.push(glob);
}
return out;
Expand Down Expand Up @@ -1893,6 +1897,13 @@ export function resolveReviewPreMergeChecks(manifest: FocusManifest | null): Pre
/** Resolve `review.enrichment` analyzer toggles from a possibly-null manifest (null = load failure ⇒ no toggles ⇒
* the operator's default analyzer set runs unchanged). Centralized so the enrichment caller threads them in one
* place with the null-manifest branch covered here (unit-tested) rather than inline in the processor. (#2050) */
/** Resolve `review.auto_review` from a possibly-null manifest (null = load failure => no ignored authors). The
* runtime eligibility check then fails open instead of suppressing review output on an ambiguous manifest read.
* (#2060) */
export function resolveReviewAutoReviewConfig(manifest: FocusManifest | null): AutoReviewConfig {
return manifest?.review.autoReview ?? { ...EMPTY_AUTO_REVIEW_CONFIG };
}

export function resolveEnrichmentAnalyzerToggles(manifest: FocusManifest | null): Partial<Record<ReesAnalyzerName, boolean>> {
return manifest?.review.enrichmentAnalyzers ?? {};
}
Expand Down
5 changes: 5 additions & 0 deletions src/signals/settings-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "./engine";
import { REQUIRED_INSTALLATION_PERMISSIONS } from "../github/backfill";
import { GITTENSORY_GATE_CHECK_NAME, shouldPublishReviewCheck } from "../review/check-names";
import { decideReviewEligibility } from "../review/review-eligibility";
import { requiredAgentActionPermissions } from "../settings/agent-execution";

export function hasVisiblePrSurface(settings: RepositorySettings): boolean {
Expand All @@ -39,6 +40,7 @@ export type PublicSurfaceSkipReason =
| "surface_off"
| "missing_author"
| "bot_author"
| "ignored_author"
| "maintainer_author"
| "miner_detection_unavailable"
| "not_official_gittensor_miner";
Expand All @@ -50,6 +52,7 @@ export type PublicSurfaceDecisionInput = {
authorLogin?: string | null | undefined;
authorType?: string | null | undefined;
authorAssociation?: string | null | undefined;
ignoredAuthorPatterns?: readonly string[] | null | undefined;
minerStatus: PublicSurfaceMinerStatus;
};

Expand All @@ -67,6 +70,7 @@ const SKIP_SUMMARY: Record<PublicSurfaceSkipReason, string> = {
surface_off: "Public surface and check runs are both disabled for this repo; nothing would be posted.",
missing_author: "The pull request has no resolvable author login; Gittensory would skip it.",
bot_author: "The author is a bot account; Gittensory would skip it.",
ignored_author: "The author matches review.auto_review.ignore_authors; Gittensory would skip it.",
maintainer_author: "The author is a maintainer (owner/member/collaborator) and maintainer authors are excluded by this repo's settings.",
miner_detection_unavailable: "Official Gittensor miner detection is unavailable, so Gittensory would skip rather than guess.",
not_official_gittensor_miner: "The author is not a confirmed Gittensor miner; Gittensory would stay quiet.",
Expand All @@ -86,6 +90,7 @@ export function decidePublicSurface(input: PublicSurfaceDecisionInput): PublicSu
if (!hasVisiblePrSurface(settings)) return skipDecision("surface_off");
if (!input.authorLogin) return skipDecision("missing_author");
if (input.authorType === "Bot" || /\[bot\]$/i.test(input.authorLogin)) return skipDecision("bot_author");
if (!decideReviewEligibility({ authorLogin: input.authorLogin, ignoreAuthors: input.ignoredAuthorPatterns }).eligible) return skipDecision("ignored_author");
if (!settings.includeMaintainerAuthors && input.authorAssociation && ["OWNER", "MEMBER", "COLLABORATOR"].includes(input.authorAssociation)) {
return skipDecision("maintainer_author");
}
Expand Down
19 changes: 17 additions & 2 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4059,6 +4059,15 @@ describe("api routes", () => {
metadata: secretMetadata,
createdAt: "2026-05-28T00:00:03.000Z",
});
await recordAuditEvent(env, {
eventType: "github_app.pr_visibility_skipped",
actor: "ignored-secret",
targetKey: "repo-owner/owned-repo#8",
outcome: "completed",
detail: "ignored_author",
metadata: secretMetadata,
createdAt: "2026-05-28T00:00:02.500Z",
});
await recordAuditEvent(env, {
eventType: "github_app.pr_visibility_skipped",
actor: "surface-secret",
Expand Down Expand Up @@ -4105,6 +4114,12 @@ describe("api routes", () => {
expect(reasonFilteredBody.limit).toBe(100);
expect(reasonFilteredBody.hasMore).toBe(false);
expect(reasonFilteredBody.items).toEqual([expect.objectContaining({ reason: "bot_author", pullNumber: 4 })]);
const ignoredFiltered = await app.request("/v1/app/skipped-pr-audit?reason=ignored_author&limit=100", { headers: apiHeaders(env) }, env);
expect(ignoredFiltered.status).toBe(200);
const ignoredFilteredBody = (await ignoredFiltered.json()) as { items: Array<{ reason: string; pullNumber: number; remediation: string }> };
expect(ignoredFilteredBody.items).toEqual([
expect.objectContaining({ reason: "ignored_author", pullNumber: 8, remediation: expect.stringContaining("manifest") }),
]);
const staticRepoFiltered = await app.request("/v1/app/skipped-pr-audit?repoFullName=repo-owner/owned-repo&limit=100", { headers: apiHeaders(env) }, env);
expect(staticRepoFiltered.status).toBe(200);
const staticRepoFilteredBody = (await staticRepoFiltered.json()) as { items: Array<{ reason: string; remediation: string }> };
Expand All @@ -4126,9 +4141,9 @@ describe("api routes", () => {
const ownerAudit = await app.request("/v1/app/skipped-pr-audit", { headers: ownerHeaders }, env);
expect(ownerAudit.status).toBe(200);
const ownerAuditBody = (await ownerAudit.json()) as { items: Array<{ repoFullName: string; reason: string }> };
expect(ownerAuditBody.items).toHaveLength(6);
expect(ownerAuditBody.items).toHaveLength(7);
expect(ownerAuditBody.items.map((item) => item.reason)).toEqual(
expect.arrayContaining(["not_official_gittensor_miner", "bot_author", "miner_detection_unavailable", "surface_off", "missing_author", "legacy_skip_reason"]),
expect.arrayContaining(["not_official_gittensor_miner", "bot_author", "miner_detection_unavailable", "surface_off", "missing_author", "ignored_author", "legacy_skip_reason"]),
);
expect(JSON.stringify(ownerAuditBody)).not.toContain("victim-org");

Expand Down
Loading
Loading