diff --git a/src/api/routes.ts b/src/api/routes.ts index 5fbe1ca56b..aa8ceff4ab 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -2528,7 +2528,21 @@ const APP_COMMANDS = [ endpoint: "/v1/app/commands/preview", }, ...GITTENSORY_MENTION_COMMAND_CATALOG.filter( - (command) => !["preflight", "blockers", "packet", "queue-summary", "review-now", "needs-author", "confirmed-miners", "duplicate-clusters"].includes(command.id), + (command) => + ![ + "preflight", + "blockers", + "packet", + "queue-summary", + "review-now", + "needs-author", + "confirmed-miners", + "duplicate-clusters", + "burden-forecast", + "intake-health", + "outcome-patterns", + "noise-report", + ].includes(command.id), ).map((command) => ({ id: command.id, command: `@gittensory ${command.id}`, @@ -2577,6 +2591,38 @@ const APP_COMMANDS = [ description: "List duplicate or WIP clusters visible from cached GitHub metadata.", endpoint: "/v1/app/maintainer-dashboard", }, + { + id: "burden-forecast", + command: "@gittensory burden-forecast", + audience: "maintainer", + boundary: "public-safe", + description: "Project maintainer review load and queue-growth risk from cached metadata.", + endpoint: "/v1/app/maintainer-dashboard", + }, + { + id: "intake-health", + command: "@gittensory intake-health", + audience: "maintainer", + boundary: "public-safe", + description: "Summarize contributor-intake health from cached queue and config signals.", + endpoint: "/v1/app/maintainer-dashboard", + }, + { + id: "outcome-patterns", + command: "@gittensory outcome-patterns", + audience: "maintainer", + boundary: "public-safe", + description: "Summarize what this repo actually merges vs closes from cached PR outcomes.", + endpoint: "/v1/app/maintainer-dashboard", + }, + { + id: "noise-report", + command: "@gittensory noise-report", + audience: "maintainer", + boundary: "public-safe", + description: "Highlight queue noise sources maintainers should triage first.", + endpoint: "/v1/app/maintainer-dashboard", + }, ] as const; function authRedirectWithError(env: Env, reason: string): string { diff --git a/src/github/commands.ts b/src/github/commands.ts index ddba12e762..8f5fe0061c 100644 --- a/src/github/commands.ts +++ b/src/github/commands.ts @@ -5,7 +5,19 @@ import type { GittensorContributorSnapshot, OfficialGittensorMinerDetection } fr import type { AgentActionRecord, RepositoryCommandAuthorizationPolicy } from "../types"; import type { CheckSummaryRecord, GitHubIssuePayload, IssueRecord, PullRequestRecord, RecentMergedPullRequestRecord, RepositoryRecord } from "../types"; import { evaluateCommandAuthorization } from "../settings/command-authorization"; -import { buildCollisionReport, buildQueueHealth, type CollisionCluster, type QueueHealth } from "../signals/engine"; +import { + buildBurdenForecast, + buildCollisionReport, + buildContributorIntakeHealth, + buildQueueHealth, + buildRepoOutcomePatterns, + type BurdenForecast, + type CollisionCluster, + type ContributorIntakeHealth, + type QueueHealth, + type RepoOutcomePatterns, +} from "../signals/engine"; +import { buildMaintainerNoiseReport, type MaintainerNoiseReport } from "../signals/reward-risk"; const PUBLIC_MENTION_COMMAND_CATALOG = [ { id: "help", title: "Gittensory command help", description: "Show public-safe @gittensory command help." }, @@ -26,6 +38,10 @@ const MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG = [ { id: "review-now", title: "Gittensory review-now queue", description: "List cached PRs that look ready for maintainer review." }, { id: "needs-author", title: "Gittensory needs-author queue", description: "List cached PRs that need author cleanup before detailed review." }, { id: "duplicate-clusters", title: "Gittensory duplicate clusters", description: "List duplicate or WIP clusters visible from cached GitHub metadata." }, + { id: "burden-forecast", title: "Gittensory burden forecast", description: "Project maintainer review load and queue-growth risk from cached metadata." }, + { id: "intake-health", title: "Gittensory intake health", description: "Summarize contributor-intake health from cached queue and config signals." }, + { id: "outcome-patterns", title: "Gittensory outcome patterns", description: "Summarize what this repo actually merges vs closes from cached PR outcomes." }, + { id: "noise-report", title: "Gittensory noise report", description: "Highlight queue noise sources maintainers should triage first." }, ] as const; export const GITTENSORY_MENTION_COMMAND_CATALOG = [...PUBLIC_MENTION_COMMAND_CATALOG, ...MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG] as const; @@ -127,6 +143,10 @@ export type MaintainerQueueDigest = { needsAuthorPullRequests: MaintainerQueuePullRequestSummary[]; confirmedMinerPullRequests: MaintainerQueuePullRequestSummary[]; duplicateClusters: MaintainerDuplicateClusterSummary[]; + burdenForecast: BurdenForecast; + intakeHealth: ContributorIntakeHealth; + outcomePatterns: RepoOutcomePatterns; + noiseReport: MaintainerNoiseReport; sourceNotes: string[]; controlPanelUrl?: string | null | undefined; }; @@ -379,6 +399,14 @@ function commandSummary(command: GittensoryMentionCommandName): string { return "Maintainer-only author-cleanup queue candidates from cached PR state."; case "duplicate-clusters": return "Maintainer-only duplicate and WIP cluster summary from cached metadata."; + case "burden-forecast": + return "Maintainer-only review-load and queue-growth forecast from cached metadata."; + case "intake-health": + return "Maintainer-only contributor-intake health summary from cached queue and config signals."; + case "outcome-patterns": + return "Maintainer-only summary of what this repo merges vs closes from cached PR outcomes."; + case "noise-report": + return "Maintainer-only queue-noise summary highlighting what to triage first."; } } @@ -443,6 +471,14 @@ function commandNextActions(command: GittensoryMentionCommandName, bundle: Agent return ["Ask authors to clear visible cleanup items before detailed review."]; case "duplicate-clusters": return ["Triage duplicate or WIP overlap before requesting deeper review."]; + case "burden-forecast": + return ["Use this forecast to plan review capacity; rerun after the queue changes."]; + case "intake-health": + return ["Address the lowest intake-health signals before inviting more contributions."]; + case "outcome-patterns": + return ["Steer contributors toward the patterns this repo actually merges."]; + case "noise-report": + return ["Clear the listed noise sources before deeper review to reduce queue drag."]; } } @@ -536,6 +572,10 @@ function commandSections( case "review-now": case "needs-author": case "duplicate-clusters": + case "burden-forecast": + case "intake-health": + case "outcome-patterns": + case "noise-report": return maintainerDigestSections(command, maintainerDigest); } } @@ -559,6 +599,10 @@ function helpSections(): string[] { "- `@gittensory needs-author` lists PRs that need author cleanup.", "- `@gittensory confirmed-miners` lists cached confirmed-miner PRs.", "- `@gittensory duplicate-clusters` lists duplicate/WIP clusters.", + "- `@gittensory burden-forecast` projects maintainer review load and queue-growth risk.", + "- `@gittensory intake-health` summarizes contributor-intake health.", + "- `@gittensory outcome-patterns` summarizes what the repo merges vs closes.", + "- `@gittensory noise-report` highlights queue noise to triage first.", ]; } @@ -1018,7 +1062,15 @@ function maintainerDigestSections(command: MaintainerQueueDigestCommandName, dig ? listPrSection("Review-now candidates", digest.reviewNowPullRequests, "No cached PR currently looks ready for detailed review.") : command === "needs-author" ? listPrSection("Needs-author queue", digest.needsAuthorPullRequests, "No cached PR currently needs obvious author cleanup first.") - : duplicateClusterSection(digest); + : command === "duplicate-clusters" + ? duplicateClusterSection(digest) + : command === "burden-forecast" + ? burdenForecastSection(digest.burdenForecast) + : command === "intake-health" + ? intakeHealthSection(digest.intakeHealth) + : command === "outcome-patterns" + ? outcomePatternsSection(digest.outcomePatterns) + : noiseReportSection(digest.noiseReport); return [ ...commandSpecific, "", @@ -1075,6 +1127,75 @@ function duplicateClusterSection(digest: MaintainerQueueDigest): string[] { ]; } +// Render up to the top three signal findings as public-safe bullets. Prefers the finding's +// explicit publicText (already vetted for a public audience) over the internal title, and routes +// every line through publicBlockerDetail so no private readiness/scoring vocabulary leaks. +function findingDigestLines(findings: Array<{ title: string; publicText?: string | undefined }>): string[] { + return findings.slice(0, 3).map((finding) => `- ${publicBlockerDetail(finding.publicText ?? finding.title)}`); +} + +// `@gittensory burden-forecast` renderer: surfaces the maintainer review-load / queue-growth +// forecast (level, projected load, reviewable/stale counts) so maintainers can plan capacity. +function burdenForecastSection(forecast: BurdenForecast): string[] { + return [ + "**Burden forecast**", + "", + `- Forecast level: ${forecast.level} (horizon ${forecast.horizonDays} days).`, + `- ${publicBlockerDetail(forecast.summary)}`, + `- Projected review load: ${forecast.forecast.projectedReviewLoad}; queue-growth risk: ${forecast.forecast.queueGrowthRisk}.`, + `- Reviewable PRs: ${forecast.forecast.reviewablePullRequests}; stale PRs: ${forecast.forecast.stalePullRequests}; duplicate trend: ${forecast.forecast.duplicateTrend}.`, + ...findingDigestLines(forecast.findings), + ]; +} + +// `@gittensory intake-health` renderer: summarizes how healthy contributor intake is (level, config +// quality, duplicate clusters, reviewable PRs) so maintainers can see whether the repo is set up to +// absorb more contributions before inviting them. +function intakeHealthSection(intake: ContributorIntakeHealth): string[] { + return [ + "**Contributor intake health**", + "", + `- Intake level: ${intake.level}.`, + `- ${publicBlockerDetail(intake.summary)}`, + `- Config quality: ${intake.configLevel}; duplicate clusters: ${intake.duplicateClusters}; reviewable PRs: ${intake.reviewablePullRequests}.`, + ...findingDigestLines(intake.findings), + ]; +} + +// `@gittensory outcome-patterns` renderer: summarizes what the repo actually merges vs closes +// (totals, merge rates, and the top success/risk pattern when present) so maintainers can steer +// contributors toward the patterns that get merged. The success/risk lines are omitted when the +// cached sample has no pattern of that kind. +function outcomePatternsSection(patterns: RepoOutcomePatterns): string[] { + return [ + "**Outcome patterns**", + "", + `- Lane: ${patterns.lane}; PRs analyzed: ${patterns.totals.analyzed}.`, + `- ${publicBlockerDetail(patterns.summary)}`, + `- Merged: ${patterns.totals.merged}; closed unmerged: ${patterns.totals.closedUnmerged}; open active: ${patterns.totals.openActive}; open stale: ${patterns.totals.openStale}.`, + `- Outside-contributor merge rate: ${Math.round(patterns.outsideContributorMergeRate * 100)}%; maintainer-lane merge rate: ${Math.round(patterns.maintainerLaneMergeRate * 100)}%.`, + ...(patterns.successPatterns.length > 0 ? [`- Merges when: ${publicBlockerDetail(patterns.successPatterns[0]!.detail)}`] : []), + ...(patterns.riskPatterns.length > 0 ? [`- Closes when: ${publicBlockerDetail(patterns.riskPatterns[0]!.detail)}`] : []), + ]; +} + +// `@gittensory noise-report` renderer: highlights the queue-noise sources maintainers should triage +// first (level, up to five noise sources, and the suggested triage actions). Falls back to a +// "no obvious noise" line when the cached metadata shows none, and omits the triage line when there +// are no suggested actions. +function noiseReportSection(noise: MaintainerNoiseReport): string[] { + return [ + "**Noise report**", + "", + `- Noise level: ${noise.level}.`, + `- ${publicBlockerDetail(noise.summary)}`, + ...(noise.noiseSources.length > 0 + ? noise.noiseSources.slice(0, 5).map((source) => `- ${publicBlockerDetail(source)}`) + : ["- No obvious queue noise source is visible from cached metadata."]), + ...(noise.maintainerActions.length > 0 ? [`- Suggested triage: ${noise.maintainerActions.map((action) => publicBlockerDetail(action)).join(", ")}.`] : []), + ]; +} + function formatPrDigestItem(item: MaintainerQueuePullRequestSummary): string { const author = item.authorLogin ? ` by @${item.authorLogin}` : ""; const linked = item.linkedIssues.length > 0 ? ` Linked: ${item.linkedIssues.map((issue) => `#${issue}`).join(", ")}.` : ""; @@ -1104,6 +1225,15 @@ export function buildMaintainerQueueDigest(args: { .sort(reviewNowSort); const confirmedMinerPullRequests = summaries.filter((item) => item.confirmedMiner).sort(reviewNowSort); const duplicateClusters = collisions.clusters.filter(isDuplicateWorkCluster).map(toMaintainerDuplicateClusterSummary); + // Compute the maintainer-intelligence reports that back the burden-forecast / intake-health / + // outcome-patterns / noise-report commands, reusing the already-computed collision report so the + // digest stays a single deterministic pass over the cached metadata. They are command-agnostic; + // maintainerDigestSections() picks the relevant one per command. + const recentMergedPullRequests = args.recentMergedPullRequests ?? []; + const burdenForecast = buildBurdenForecast(args.repo, args.issues, args.pullRequests, collisions); + const intakeHealth = buildContributorIntakeHealth(args.repo, args.issues, args.pullRequests, repoFullName, collisions); + const outcomePatterns = buildRepoOutcomePatterns({ repo: args.repo, repoFullName, pullRequests: args.pullRequests, recentMergedPullRequests }); + const noiseReport = buildMaintainerNoiseReport(args.repo, args.issues, args.pullRequests, recentMergedPullRequests, repoFullName); return { repoFullName, generatedAt: new Date().toISOString(), @@ -1128,6 +1258,10 @@ export function buildMaintainerQueueDigest(args: { needsAuthorPullRequests, confirmedMinerPullRequests, duplicateClusters, + burdenForecast, + intakeHealth, + outcomePatterns, + noiseReport, sourceNotes: [ "Queue digest uses cached GitHub issues, pull requests, recent merges, checks, PR age, and official-miner cache entries.", "Private evidence, detailed blockers, and full command history require authenticated dashboard/API access.", diff --git a/src/settings/command-authorization.ts b/src/settings/command-authorization.ts index 67b704c0d6..75f7bffe79 100644 --- a/src/settings/command-authorization.ts +++ b/src/settings/command-authorization.ts @@ -8,6 +8,10 @@ export const DEFAULT_COMMAND_AUTHORIZATION_POLICY: RepositoryCommandAuthorizatio "review-now": ["maintainer", "collaborator"], "needs-author": ["maintainer", "collaborator"], "duplicate-clusters": ["maintainer", "collaborator"], + "burden-forecast": ["maintainer", "collaborator"], + "intake-health": ["maintainer", "collaborator"], + "outcome-patterns": ["maintainer", "collaborator"], + "noise-report": ["maintainer", "collaborator"], }, }; diff --git a/test/unit/github-commands.test.ts b/test/unit/github-commands.test.ts index 5eec68f558..3675151103 100644 --- a/test/unit/github-commands.test.ts +++ b/test/unit/github-commands.test.ts @@ -1368,6 +1368,90 @@ describe("GitHub mention commands", () => { expect(duplicateBlockers.match(/Private readiness context available in authenticated Gittensory views/g)).toHaveLength(1); }); + it("renders the new maintainer intelligence commands public-safely", () => { + const digest = sampleMaintainerDigest(); + expect(digest.burdenForecast.repoFullName).toBe("owner/repo"); + expect(digest.intakeHealth.repoFullName).toBe("owner/repo"); + expect(digest.outcomePatterns.repoFullName).toBe("owner/repo"); + expect(digest.noiseReport.repoFullName).toBe("owner/repo"); + + const FORBIDDEN = /wallet|hotkey|coldkey|mnemonic|raw trust score|trust score|payout|reward estimate|farming|private reviewability|scoreability/i; + const render = (mention: string) => + buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand(mention)!, + repo: { fullName: "owner/repo" } as any, + issue: { number: 99, title: "Digest", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "maintainer", + maintainerDigest: digest, + }); + + const burden = render("@gittensory burden-forecast"); + expect(burden).toContain("**Gittensory burden forecast**"); + expect(burden).toContain("**Burden forecast**"); + expect(burden).toContain("Forecast level:"); + expect(burden).not.toMatch(FORBIDDEN); + + const intake = render("@gittensory intake-health"); + expect(intake).toContain("**Contributor intake health**"); + expect(intake).toContain("Intake level:"); + expect(intake).not.toMatch(FORBIDDEN); + + const outcomes = render("@gittensory outcome-patterns"); + expect(outcomes).toContain("**Outcome patterns**"); + expect(outcomes).toContain("Lane:"); + expect(outcomes).not.toMatch(FORBIDDEN); + + const noise = render("@gittensory noise-report"); + expect(noise).toContain("**Noise report**"); + expect(noise).toContain("Noise level:"); + expect(noise).not.toMatch(FORBIDDEN); + + expect(parseGittensoryMentionCommand("@gittensory burden-forecast")?.name).toBe("burden-forecast"); + expect(isMaintainerOnlyCommand("noise-report")).toBe(true); + }); + + it("renders populated and empty outcome/noise report variants", () => { + const base = sampleMaintainerDigest(); + const render = (mention: string, digest: typeof base) => + buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand(mention)!, + repo: { fullName: "owner/repo" } as any, + issue: { number: 99, title: "Digest", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "maintainer", + maintainerDigest: digest, + }); + + const populated = { + ...base, + outcomePatterns: { + ...base.outcomePatterns, + successPatterns: [{ title: "Linked + tested", detail: "Merged PRs link an issue and include validation notes.", confidence: "high" as const }], + riskPatterns: [{ title: "Unlinked churn", detail: "Closed PRs often lacked a linked issue.", confidence: "medium" as const }], + }, + noiseReport: { ...base.noiseReport, noiseSources: ["3 open PR(s) lack linked issue context."], maintainerActions: ["needs_author" as const, "review_now" as const] }, + }; + const populatedOutcomes = render("@gittensory outcome-patterns", populated); + expect(populatedOutcomes).toContain("Merges when:"); + expect(populatedOutcomes).toContain("Closes when:"); + const populatedNoise = render("@gittensory noise-report", populated); + expect(populatedNoise).toContain("lack linked issue context"); + expect(populatedNoise).toContain("Suggested triage:"); + + const empty = { + ...base, + outcomePatterns: { ...base.outcomePatterns, successPatterns: [], riskPatterns: [] }, + noiseReport: { ...base.noiseReport, noiseSources: [], maintainerActions: [] }, + }; + const emptyOutcomes = render("@gittensory outcome-patterns", empty); + expect(emptyOutcomes).not.toContain("Merges when:"); + expect(emptyOutcomes).not.toContain("Closes when:"); + const emptyNoise = render("@gittensory noise-report", empty); + expect(emptyNoise).toContain("No obvious queue noise source"); + expect(emptyNoise).not.toContain("Suggested triage:"); + }); + it("builds maintainer-only queue digests with safe routing, sorting, and private-detail pointers", () => { const digest = sampleMaintainerDigest(); expect(digest.totals.confirmedMinerPullRequests).toBe(2);