diff --git a/src/api/routes.ts b/src/api/routes.ts index c967ccb705..ca974ff29b 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -1958,7 +1958,9 @@ const APP_COMMANDS = [ description: "Preview the public-safe summary that may be posted to a PR thread.", endpoint: "/v1/app/commands/preview", }, - ...GITTENSORY_MENTION_COMMAND_CATALOG.filter((command) => !["help", "preflight", "blockers", "packet"].includes(command.id)).map((command) => ({ + ...GITTENSORY_MENTION_COMMAND_CATALOG.filter( + (command) => !["help", "preflight", "blockers", "packet", "queue-summary", "review-now", "needs-author", "confirmed-miners", "duplicate-clusters"].includes(command.id), + ).map((command) => ({ id: command.id, command: `@gittensory ${command.id}`, audience: "public-safe", @@ -1966,6 +1968,46 @@ const APP_COMMANDS = [ description: command.description, endpoint: "GitHub issue comment", })), + { + id: "queue-summary", + command: "@gittensory queue-summary", + audience: "maintainer", + boundary: "public-safe", + description: "Post a maintainer-only queue digest from cached GitHub metadata.", + endpoint: "/v1/app/maintainer-dashboard", + }, + { + id: "review-now", + command: "@gittensory review-now", + audience: "maintainer", + boundary: "public-safe", + description: "List cached PRs that look ready for maintainer review.", + endpoint: "/v1/app/maintainer-dashboard", + }, + { + id: "needs-author", + command: "@gittensory needs-author", + audience: "maintainer", + boundary: "public-safe", + description: "List cached PRs that need author cleanup before detailed review.", + endpoint: "/v1/app/maintainer-dashboard", + }, + { + id: "confirmed-miners", + command: "@gittensory confirmed-miners", + audience: "maintainer", + boundary: "public-safe", + description: "List open PRs whose authors are confirmed in the official-miner cache.", + endpoint: "/v1/app/maintainer-dashboard", + }, + { + id: "duplicate-clusters", + command: "@gittensory duplicate-clusters", + audience: "maintainer", + boundary: "public-safe", + description: "List duplicate or WIP clusters visible from cached GitHub metadata.", + 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 ef36b49b0f..620dc31a5e 100644 --- a/src/github/commands.ts +++ b/src/github/commands.ts @@ -2,9 +2,10 @@ import { AGENT_COMMAND_COMMENT_MARKER } from "./comments"; import type { AgentRunBundle } from "../services/agent-orchestrator"; import type { GittensorContributorSnapshot, OfficialGittensorMinerDetection } from "../gittensor/api"; import type { AgentActionRecord } from "../types"; -import type { GitHubIssuePayload, PullRequestRecord, RepositoryRecord } from "../types"; +import type { CheckSummaryRecord, GitHubIssuePayload, IssueRecord, PullRequestRecord, RecentMergedPullRequestRecord, RepositoryRecord } from "../types"; +import { buildCollisionReport, buildQueueHealth, type CollisionCluster, type QueueHealth } from "../signals/engine"; -export const GITTENSORY_MENTION_COMMAND_CATALOG = [ +const PUBLIC_MENTION_COMMAND_CATALOG = [ { id: "help", title: "Gittensory command help", description: "Show public-safe @gittensory command help." }, { id: "preflight", title: "Gittensory preflight", description: "Summarize public PR hygiene and validation readiness." }, { id: "blockers", title: "Gittensory readiness blockers", description: "Explain public-safe readiness blockers." }, @@ -16,7 +17,19 @@ export const GITTENSORY_MENTION_COMMAND_CATALOG = [ { id: "packet", title: "Gittensory public packet", description: "Prepare public-safe PR packet guidance." }, ] as const; +const MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG = [ + { id: "queue-summary", title: "Gittensory maintainer queue summary", description: "Post a maintainer-only queue digest from cached GitHub metadata." }, + { id: "confirmed-miners", title: "Gittensory confirmed-miner PRs", description: "List open PRs whose authors are confirmed in the official-miner cache." }, + { 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." }, +] as const; + +export const GITTENSORY_MENTION_COMMAND_CATALOG = [...PUBLIC_MENTION_COMMAND_CATALOG, ...MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG] as const; + export type GittensoryMentionCommandName = (typeof GITTENSORY_MENTION_COMMAND_CATALOG)[number]["id"]; +export type MaintainerQueueDigestCommandName = (typeof MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG)[number]["id"]; +type SnapshotCommandName = Exclude; export type GittensoryMentionCommand = { name: GittensoryMentionCommandName; @@ -34,11 +47,12 @@ type PublicAnswerCard = { }; const COMMANDS = new Set(GITTENSORY_MENTION_COMMAND_CATALOG.map((command) => command.id)); +const MAINTAINER_QUEUE_DIGEST_COMMANDS = new Set(MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG.map((command) => command.id)); const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const COMMAND_TITLES = Object.fromEntries(GITTENSORY_MENTION_COMMAND_CATALOG.map((command) => [command.id, command.title])) as Record; -const REFRESH_SECTION_TITLES: Record, string> = { +const REFRESH_SECTION_TITLES: Record = { preflight: "Preflight snapshot refresh", blockers: "Blocker snapshot refresh", "duplicate-check": "Duplicate-check snapshot refresh", @@ -48,7 +62,7 @@ const REFRESH_SECTION_TITLES: Record, string> = { +const EMPTY_SECTION_TITLES: Record = { preflight: "Preflight summary", blockers: "Readiness blockers", "duplicate-check": "Duplicate & WIP caution", @@ -58,6 +72,53 @@ const EMPTY_SECTION_TITLES: Record; + reasons: string[]; +}; + +export type MaintainerDuplicateClusterSummary = { + id: string; + risk: "medium" | "high"; + reason: string; + items: Array<{ type: "issue" | "pull_request" | "recent_merged_pull_request"; number: number; title: string }>; +}; + +export type MaintainerQueueDigest = { + repoFullName: string; + generatedAt: string; + queue: { + level: QueueHealth["level"]; + openIssues: number; + openPullRequests: number; + unlinkedPullRequests: number; + stalePullRequests: number; + likelyReviewablePullRequests: number; + maintainerAuthoredPullRequests: number; + duplicateClusters: number; + highRiskDuplicateClusters: number; + }; + totals: { + reviewNow: number; + needsAuthor: number; + confirmedMinerPullRequests: number; + duplicateClusters: number; + }; + reviewNowPullRequests: MaintainerQueuePullRequestSummary[]; + needsAuthorPullRequests: MaintainerQueuePullRequestSummary[]; + confirmedMinerPullRequests: MaintainerQueuePullRequestSummary[]; + duplicateClusters: MaintainerDuplicateClusterSummary[]; + sourceNotes: string[]; + controlPanelUrl?: string | null | undefined; +}; + export function parseGittensoryMentionCommand(body: string | null | undefined): GittensoryMentionCommand | null { if (!body) return null; const match = body.match(/(?:^|\s)@gittensory(?:\s+([a-z-]+))?/i); @@ -71,13 +132,25 @@ export function isMaintainerAssociation(association: string | null | undefined): return Boolean(association && MAINTAINER_ASSOCIATIONS.has(association)); } +export function isMaintainerQueueDigestCommand(command: GittensoryMentionCommandName): command is MaintainerQueueDigestCommandName { + return MAINTAINER_QUEUE_DIGEST_COMMANDS.has(command as MaintainerQueueDigestCommandName); +} + +export function isMaintainerOnlyCommand(command: GittensoryMentionCommandName): boolean { + return isMaintainerQueueDigestCommand(command); +} + export function isAuthorizedCommandActor(args: { + commandName?: GittensoryMentionCommandName | undefined; commenterLogin?: string | null | undefined; commenterAssociation?: string | null | undefined; pullRequestAuthorLogin?: string | null | undefined; officialAuthorDetection?: OfficialGittensorMinerDetection | undefined; }): { authorized: boolean; reason: string; actorKind: "maintainer" | "author" | "none" } { if (isMaintainerAssociation(args.commenterAssociation)) return { authorized: true, reason: "maintainer_invocation", actorKind: "maintainer" }; + if (args.commandName && isMaintainerOnlyCommand(args.commandName)) { + return { authorized: false, reason: "maintainer_command_requires_maintainer", actorKind: "none" }; + } if (!args.commenterLogin || !args.pullRequestAuthorLogin || args.commenterLogin.toLowerCase() !== args.pullRequestAuthorLogin.toLowerCase()) { return { authorized: false, reason: "not_maintainer_or_pr_author", actorKind: "none" }; } @@ -98,9 +171,10 @@ export function buildPublicAgentCommandComment(args: { actorKind: "maintainer" | "author"; officialMiner?: GittensorContributorSnapshot | null | undefined; bundle?: AgentRunBundle | null | undefined; + maintainerDigest?: MaintainerQueueDigest | null | undefined; }): string { const repoFullName = args.repo?.fullName ?? args.pullRequest?.repoFullName ?? "this repository"; - const sections = commandSections(args.command.name, args.bundle, args.officialMiner); + const sections = commandSections(args.command.name, args.bundle, args.officialMiner, args.maintainerDigest); const card = buildPublicAnswerCard({ command: args.command.name, sections, @@ -194,6 +268,16 @@ function commandSummary(command: GittensoryMentionCommandName): string { return "Public-safe repository fit signals from cached context."; case "packet": return "Public-safe PR packet guidance for the current thread."; + case "queue-summary": + return "Maintainer-only queue-level digest from cached GitHub metadata."; + case "confirmed-miners": + return "Maintainer-only confirmed-miner PR list from cached queue metadata."; + case "review-now": + return "Maintainer-only review-now queue candidates from cached PR state."; + case "needs-author": + 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."; } } @@ -207,6 +291,10 @@ function commandEvidence( if (command === "miner-context") { evidence.push(officialMiner ? "Official Gittensor miner context was available." : "Official Gittensor miner context was unavailable."); } + if (isMaintainerQueueDigestCommand(command)) { + evidence.push("Maintainer-only queue digest command was authorized from GitHub author association."); + evidence.push("Digest uses cached public GitHub queue metadata plus official-miner cache."); + } if (bundle) { evidence.push(`Agent response status: ${publicStatus(bundle.run.status)}.`); } @@ -234,6 +322,16 @@ function commandNextActions(command: GittensoryMentionCommandName, bundle: Agent return ["Use MCP or the authenticated control panel for deeper private repository-fit planning."]; case "packet": return ["Use this as public PR-thread guidance only; keep private scoring and planning details out of comments."]; + case "queue-summary": + return ["Use the authenticated maintainer dashboard for private evidence and full queue detail."]; + case "confirmed-miners": + return ["Review confirmed-miner PRs alongside linked issues before prioritizing maintainer attention."]; + case "review-now": + return ["Use this list to prioritize detailed review, then rerun after checks or queue state changes."]; + case "needs-author": + return ["Ask authors to clear visible cleanup items before detailed review."]; + case "duplicate-clusters": + return ["Triage duplicate or WIP overlap before requesting deeper review."]; } } @@ -249,6 +347,8 @@ function commandSourceNotes( ? officialMiner ? "official Gittensor miner API" : "official miner check fallback" + : isMaintainerQueueDigestCommand(command) + ? "cached GitHub queue metadata and official-miner cache" : "cached Gittensory agent context"; return [ `Source: ${source}.`, @@ -259,6 +359,7 @@ function commandSourceNotes( function publicFreshness(bundle: AgentRunBundle | null | undefined, command: GittensoryMentionCommandName): string { if (command === "help") return "shipped command list"; + if (isMaintainerQueueDigestCommand(command)) return "cached queue digest generated at invocation time"; if (!bundle) return "no agent run was required or available"; if (bundle.run.status === "needs_snapshot_refresh") return "snapshot refresh in progress"; return `agent run status ${publicStatus(bundle.run.status)}`; @@ -280,6 +381,7 @@ function commandSections( command: GittensoryMentionCommandName, bundle: AgentRunBundle | null | undefined, officialMiner: GittensorContributorSnapshot | null | undefined, + maintainerDigest: MaintainerQueueDigest | null | undefined, ): string[] { switch (command) { case "help": @@ -300,6 +402,12 @@ function commandSections( return repoFitSections(bundle); case "packet": return packetSections(bundle); + case "queue-summary": + case "confirmed-miners": + case "review-now": + case "needs-author": + case "duplicate-clusters": + return maintainerDigestSections(command, maintainerDigest); } } @@ -316,6 +424,11 @@ function helpSections(): string[] { "- `@gittensory reviewability` summarizes PR readiness without private review internals.", "- `@gittensory repo-fit` summarizes repository fit from cached public-safe signals.", "- `@gittensory packet` prepares public-safe PR packet guidance.", + "- `@gittensory queue-summary` gives maintainers cached queue-level context.", + "- `@gittensory review-now` lists maintainer-only review candidates.", + "- `@gittensory needs-author` lists PRs that need author cleanup.", + "- `@gittensory confirmed-miners` lists cached confirmed-miner PRs.", + "- `@gittensory duplicate-clusters` lists duplicate/WIP clusters.", ]; } @@ -471,7 +584,7 @@ function packetSections(bundle: AgentRunBundle | null | undefined): string[] { ]; } -function refreshSections(command: Exclude): string[] { +function refreshSections(command: SnapshotCommandName): string[] { return [ `**${REFRESH_SECTION_TITLES[command]}**`, "", @@ -479,10 +592,244 @@ function refreshSections(command: Exclude): string[] { +function emptySections(command: SnapshotCommandName): string[] { return [`**${EMPTY_SECTION_TITLES[command]}**`, "", "- No public-safe context is available from the current cached snapshot."]; } +function maintainerDigestSections(command: MaintainerQueueDigestCommandName, digest: MaintainerQueueDigest | null | undefined): string[] { + if (!digest) { + return [ + "**Maintainer queue digest**", + "", + "- Cached queue context is unavailable for this command.", + "- Use the authenticated maintainer dashboard for private evidence and full API detail.", + ]; + } + const commandSpecific = + command === "queue-summary" + ? queueSummarySections(digest) + : command === "confirmed-miners" + ? listPrSection("Confirmed-miner PRs", digest.confirmedMinerPullRequests, "No cached confirmed-miner PRs are visible in this queue.") + : command === "review-now" + ? 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); + return [ + ...commandSpecific, + "", + "**Private detail**", + "", + ...(digest.controlPanelUrl + ? [`- Authenticated control panel: ${digest.controlPanelUrl}`] + : ["- Use the authenticated maintainer dashboard and private API for full cached evidence."]), + "- Public GitHub output is limited to cached metadata and safe queue routing notes.", + "", + "**Source and freshness**", + "", + ...digest.sourceNotes.map((note) => `- ${publicBlockerDetail(note)}`), + "", + "**Feedback**", + "", + "- Feedback on this response is tracked separately from deterministic queue routing.", + ]; +} + +function queueSummarySections(digest: MaintainerQueueDigest): string[] { + return [ + "**Queue summary**", + "", + `- Queue level: ${digest.queue.level}.`, + `- Open PRs: ${digest.queue.openPullRequests}; open issues: ${digest.queue.openIssues}.`, + `- Review-now: ${digest.totals.reviewNow}; needs-author: ${digest.totals.needsAuthor}; confirmed-miner PRs: ${digest.totals.confirmedMinerPullRequests}.`, + `- Duplicate/WIP clusters: ${digest.totals.duplicateClusters}; unlinked PRs: ${digest.queue.unlinkedPullRequests}; stale PRs: ${digest.queue.stalePullRequests}.`, + `- Maintainer-authored PRs: ${digest.queue.maintainerAuthoredPullRequests}.`, + ]; +} + +function listPrSection(title: string, items: MaintainerQueuePullRequestSummary[], empty: string): string[] { + return [ + `**${title}**`, + "", + ...(items.length > 0 ? items.slice(0, 8).map(formatPrDigestItem) : [`- ${empty}`]), + ]; +} + +function duplicateClusterSection(digest: MaintainerQueueDigest): string[] { + return [ + "**Duplicate/WIP clusters**", + "", + ...(digest.duplicateClusters.length > 0 + ? digest.duplicateClusters.slice(0, 6).map((cluster) => { + const refs = cluster.items + .slice(0, 4) + .map((item) => `${item.type === "pull_request" ? "PR" : item.type === "issue" ? "issue" : "recent merge"} #${item.number}: ${shortText(item.title, 90)}`) + .join("; "); + return `- ${cluster.risk} risk: ${publicBlockerDetail(cluster.reason)} Items: ${refs}.`; + }) + : ["- No duplicate or WIP cluster is visible from cached metadata."]), + ]; +} + +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(", ")}.` : ""; + const reasons = item.reasons.slice(0, 3).join("; "); + return `- #${item.number}: ${shortText(item.title, 100)}${author}.${linked} ${reasons}`; +} + +export function buildMaintainerQueueDigest(args: { + repo: RepositoryRecord | null; + issues: IssueRecord[]; + pullRequests: PullRequestRecord[]; + recentMergedPullRequests?: RecentMergedPullRequestRecord[] | undefined; + confirmedMinerLogins?: readonly string[] | undefined; + checkSummariesByPullNumber?: Record | undefined; + controlPanelUrl?: string | null | undefined; +}): MaintainerQueueDigest { + const repoFullName = args.repo?.fullName ?? args.pullRequests[0]?.repoFullName ?? args.issues[0]?.repoFullName ?? "this repository"; + const openPullRequests = args.pullRequests.filter((pr) => pr.state === "open"); + const collisions = buildCollisionReport(repoFullName, args.issues, args.pullRequests, args.recentMergedPullRequests ?? []); + const queueHealth = buildQueueHealth(args.repo, args.issues, args.pullRequests, collisions); + const confirmedMinerLogins = new Set((args.confirmedMinerLogins ?? []).map(normalizeLogin)); + const duplicatePrNumbers = duplicatePullRequestNumbers(collisions.clusters); + const summaries = openPullRequests.map((pr) => summarizeQueuePullRequest(pr, confirmedMinerLogins, duplicatePrNumbers, args.checkSummariesByPullNumber?.[pr.number] ?? [])); + const needsAuthorPullRequests = summaries.filter(needsAuthorFirst).sort(needsAuthorSort); + const reviewNowPullRequests = summaries + .filter((item) => !needsAuthorFirst(item) && item.linkedIssues.length > 0 && !item.signals.includes("draft")) + .sort(reviewNowSort); + const confirmedMinerPullRequests = summaries.filter((item) => item.confirmedMiner).sort(reviewNowSort); + const duplicateClusters = collisions.clusters.filter(isDuplicateWorkCluster).map(toMaintainerDuplicateClusterSummary); + return { + repoFullName, + generatedAt: new Date().toISOString(), + queue: { + level: queueHealth.level, + openIssues: queueHealth.signals.openIssues, + openPullRequests: queueHealth.signals.openPullRequests, + unlinkedPullRequests: queueHealth.signals.unlinkedPullRequests, + stalePullRequests: queueHealth.signals.stalePullRequests, + likelyReviewablePullRequests: queueHealth.signals.likelyReviewablePullRequests, + maintainerAuthoredPullRequests: queueHealth.signals.maintainerAuthoredPullRequests, + duplicateClusters: duplicateClusters.length, + highRiskDuplicateClusters: duplicateClusters.filter((cluster) => cluster.risk === "high").length, + }, + totals: { + reviewNow: reviewNowPullRequests.length, + needsAuthor: needsAuthorPullRequests.length, + confirmedMinerPullRequests: confirmedMinerPullRequests.length, + duplicateClusters: duplicateClusters.length, + }, + reviewNowPullRequests, + needsAuthorPullRequests, + confirmedMinerPullRequests, + duplicateClusters, + 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.", + "Feedback prompt events are kept separate from deterministic queue routing.", + ], + controlPanelUrl: args.controlPanelUrl, + }; +} + +function summarizeQueuePullRequest( + pr: PullRequestRecord, + confirmedMinerLogins: Set, + duplicatePrNumbers: Set, + checks: readonly CheckSummaryRecord[], +): MaintainerQueuePullRequestSummary { + const ageDays = daysSince(pr.updatedAt ?? pr.createdAt); + const confirmedMiner = Boolean(pr.authorLogin && confirmedMinerLogins.has(normalizeLogin(pr.authorLogin))); + const failedChecks = checks.filter((check) => ["failure", "timed_out", "cancelled"].includes(check.conclusion ?? "")).length; + const signals: MaintainerQueuePullRequestSummary["signals"] = [ + ...(confirmedMiner ? ["confirmed_miner" as const] : []), + ...(pr.linkedIssues.length === 0 ? ["missing_linked_issue" as const] : []), + ...(duplicatePrNumbers.has(pr.number) ? ["duplicate_or_overlap" as const] : []), + ...(ageDays >= 14 ? ["stale" as const] : []), + ...(pr.isDraft ? ["draft" as const] : []), + ...(failedChecks > 0 ? ["checks_need_attention" as const] : []), + ...(isMaintainerAssociation(pr.authorAssociation) ? ["maintainer_authored" as const] : []), + ]; + const reasons = [ + ...(confirmedMiner ? ["Official-miner cache confirms this author."] : []), + ...(pr.linkedIssues.length > 0 ? [`Linked issue context is present (${pr.linkedIssues.map((issue) => `#${issue}`).join(", ")}).`] : ["Missing linked issue or no-issue rationale."]), + ...(duplicatePrNumbers.has(pr.number) ? ["Possible duplicate or WIP overlap needs triage first."] : []), + ...(ageDays >= 14 ? [`No cached update for ${ageDays} day(s).`] : []), + ...(pr.isDraft ? ["Draft PR should stay out of detailed review until marked ready."] : []), + ...(failedChecks > 0 ? [`${failedChecks} cached check(s) need attention.`] : []), + ...(isMaintainerAssociation(pr.authorAssociation) ? ["Maintainer-authored PR; review as repo stewardship."] : []), + ]; + return { + number: pr.number, + title: pr.title, + authorLogin: pr.authorLogin, + linkedIssues: pr.linkedIssues, + labels: pr.labels, + ageDays, + confirmedMiner, + signals, + reasons, + }; +} + +function needsAuthorFirst(item: MaintainerQueuePullRequestSummary): boolean { + return item.signals.some((signal) => signal === "missing_linked_issue" || signal === "duplicate_or_overlap" || signal === "stale" || signal === "draft" || signal === "checks_need_attention"); +} + +function reviewNowSort(left: MaintainerQueuePullRequestSummary, right: MaintainerQueuePullRequestSummary): number { + return Number(right.confirmedMiner) - Number(left.confirmedMiner) || right.linkedIssues.length - left.linkedIssues.length || right.ageDays - left.ageDays || left.number - right.number; +} + +function needsAuthorSort(left: MaintainerQueuePullRequestSummary, right: MaintainerQueuePullRequestSummary): number { + return signalRank(right) - signalRank(left) || right.ageDays - left.ageDays || left.number - right.number; +} + +function signalRank(item: MaintainerQueuePullRequestSummary): number { + return ( + (item.signals.includes("duplicate_or_overlap") ? 10 : 0) + + (item.signals.includes("checks_need_attention") ? 4 : 0) + + (item.signals.includes("missing_linked_issue") ? 3 : 0) + + (item.signals.includes("draft") ? 2 : 0) + + (item.signals.includes("stale") ? 1 : 0) + ); +} + +function duplicatePullRequestNumbers(clusters: CollisionCluster[]): Set { + return new Set(clusters.filter(isDuplicateWorkCluster).flatMap((cluster) => cluster.items.filter((item) => item.type === "pull_request").map((item) => item.number))); +} + +function isDuplicateWorkCluster(cluster: CollisionCluster): boolean { + const pullRequestCount = cluster.items.filter((item) => item.type === "pull_request").length; + const recentMergeCount = cluster.items.filter((item) => item.type === "recent_merged_pull_request").length; + return pullRequestCount > 1 || (pullRequestCount > 0 && recentMergeCount > 0); +} + +function toMaintainerDuplicateClusterSummary(cluster: CollisionCluster): MaintainerDuplicateClusterSummary { + return { + id: cluster.id, + risk: cluster.risk === "high" ? "high" : "medium", + reason: cluster.reason, + items: cluster.items.map((item) => ({ type: item.type, number: item.number, title: item.title })), + }; +} + +function daysSince(value: string | null | undefined): number { + if (!value) return 0; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return 0; + return Math.max(0, Math.floor((Date.now() - timestamp) / 86_400_000)); +} + +function normalizeLogin(value: string): string { + return value.trim().toLowerCase(); +} + +function shortText(value: string, maxLength: number): string { + const sanitized = publicBlockerDetail(value).replace(/\s+/g, " ").trim(); + return sanitized.length > maxLength ? `${sanitized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...` : sanitized; +} + function pickActions( bundle: AgentRunBundle | null | undefined, predicate: (action: AgentActionRecord) => boolean, diff --git a/src/queue/processors.ts b/src/queue/processors.ts index c7e33264cc..f5d019e17e 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -6,6 +6,7 @@ import { getPullRequest, getRepository, getRepositorySettings, + listCheckSummaries, listAllIssues, listAllPullRequests, listBounties, @@ -53,9 +54,13 @@ import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot, f import { createOrUpdateCheckRun, getInstallationId } from "../github/app"; import { createOrUpdateAgentCommandComment, createOrUpdatePrIntelligenceComment } from "../github/comments"; import { + buildMaintainerQueueDigest, buildPublicAgentCommandComment, type GittensoryMentionCommandName, isAuthorizedCommandActor, + isMaintainerAssociation, + isMaintainerOnlyCommand, + isMaintainerQueueDigestCommand, parseGittensoryMentionCommand, } from "../github/commands"; import { ensurePullRequestLabel } from "../github/labels"; @@ -754,6 +759,7 @@ async function maybeProcessGittensoryMentionCommand(env: Env, deliveryId: string const installationId = getInstallationId(payload); const commenter = payload.comment?.user?.login; const targetKey = repoFullName && issue ? `${repoFullName}#${issue.number}` : repoFullName; + const commenterAssociation = payload.comment?.author_association ?? issue?.author_association; if (!repoFullName || !issue || !installationId || !commenter) { await recordAuditEvent(env, { eventType: "github_app.agent_command_skipped", @@ -822,12 +828,42 @@ async function maybeProcessGittensoryMentionCommand(env: Env, deliveryId: string const [repo, cachedPullRequest] = await Promise.all([getRepository(env, repoFullName), getPullRequest(env, repoFullName, issue.number)]); const pullRequestAuthor = cachedPullRequest?.authorLogin ?? issue.user?.login ?? null; - const official = pullRequestAuthor + const maintainerActor = isMaintainerAssociation(commenterAssociation); + if (isMaintainerOnlyCommand(command.name) && !maintainerActor) { + await recordAuditEvent(env, { + eventType: "github_app.agent_command_skipped", + actor: commenter, + targetKey: `${repoFullName}#${issue.number}`, + outcome: "denied", + detail: "maintainer_command_requires_maintainer", + metadata: { deliveryId, command: command.name }, + }); + await recordAgentCommandUsage(env, { + repoFullName, + targetKey: `${repoFullName}#${issue.number}`, + actor: commenter, + command: command.name, + actorKind: "none", + outcome: "skipped", + detail: "maintainer_command_requires_maintainer", + family: "maintainer_digest", + }); + await recordGithubProductUsage(env, "agent_command_skipped", { + actor: commenter, + repoFullName, + targetKey: `${repoFullName}#${issue.number}`, + outcome: "denied", + metadata: { command: command.name, reason: "maintainer_command_requires_maintainer", family: "queue_digest" }, + }); + return true; + } + const official = pullRequestAuthor && (!maintainerActor || command.name === "miner-context") ? await getCachedOfficialMinerDetection(env, pullRequestAuthor, { targetKey: `${repoFullName}#${issue.number}`, deliveryId }) : undefined; const authorization = isAuthorizedCommandActor({ + commandName: command.name, commenterLogin: commenter, - commenterAssociation: payload.comment?.author_association ?? issue.author_association, + commenterAssociation, pullRequestAuthorLogin: pullRequestAuthor, officialAuthorDetection: official, }); @@ -860,12 +896,17 @@ async function maybeProcessGittensoryMentionCommand(env: Env, deliveryId: string } const login = pullRequestAuthor ?? commenter; - const bundle = await buildMentionCommandBundle(env, command.name, { - login, - repoFullName, - issue, - pullRequest: cachedPullRequest, - }); + const maintainerDigest = isMaintainerQueueDigestCommand(command.name) + ? await buildMaintainerQueueDigestForCommand(env, repo, repoFullName) + : null; + const bundle = maintainerDigest + ? null + : await buildMentionCommandBundle(env, command.name, { + login, + repoFullName, + issue, + pullRequest: cachedPullRequest, + }); const body = buildPublicAgentCommandComment({ command, repo, @@ -874,6 +915,7 @@ async function maybeProcessGittensoryMentionCommand(env: Env, deliveryId: string actorKind: authorization.actorKind === "maintainer" ? "maintainer" : "author", officialMiner: official?.status === "confirmed" ? official.snapshot : null, bundle, + maintainerDigest, }); await createOrUpdateAgentCommandComment(env, installationId, repoFullName, issue.number, body); await recordAuditEvent(env, { @@ -890,7 +932,8 @@ async function maybeProcessGittensoryMentionCommand(env: Env, deliveryId: string command: command.name, actorKind: authorization.actorKind, outcome: "replied", - detail: bundle?.run.status ?? "no_run", + detail: bundle?.run.status ?? (maintainerDigest ? "maintainer_digest" : "no_run"), + family: maintainerDigest ? "maintainer_digest" : "agent_command", runId: bundle?.run.id ?? null, }); await recordGithubProductUsage(env, "agent_command_replied", { @@ -898,7 +941,15 @@ async function maybeProcessGittensoryMentionCommand(env: Env, deliveryId: string repoFullName, targetKey: `${repoFullName}#${issue.number}`, outcome: "completed", - metadata: { command: command.name, actorKind: authorization.actorKind, hasAgentRun: Boolean(bundle) }, + metadata: { command: command.name, actorKind: authorization.actorKind, hasAgentRun: Boolean(bundle), family: maintainerDigest ? "queue_digest" : "agent_command" }, + }); + await recordAgentCommandFeedbackPrompt(env, { + deliveryId, + command: command.name, + actor: commenter, + targetKey: `${repoFullName}#${issue.number}`, + actorKind: authorization.actorKind === "maintainer" ? "maintainer" : "author", + family: maintainerDigest ? "maintainer_digest" : "agent_command", }); return true; } @@ -954,6 +1005,7 @@ async function recordAgentCommandUsage( actorKind: "maintainer" | "author" | "none"; outcome: "replied" | "skipped" | "error"; detail?: string | null | undefined; + family?: "agent_command" | "maintainer_digest" | undefined; runId?: string | null | undefined; }, ): Promise { @@ -970,6 +1022,7 @@ async function recordAgentCommandUsage( actorHash, outcome: args.outcome, detail: args.detail ?? null, + family: args.family ?? "agent_command", runId: args.runId ?? null, }, generatedAt: nowIso(), @@ -979,6 +1032,93 @@ async function recordAgentCommandUsage( } } +async function buildMaintainerQueueDigestForCommand( + env: Env, + repo: Awaited>, + repoFullName: string, +): Promise> { + const [issues, pullRequests, recentMergedPullRequests] = await Promise.all([ + listIssues(env, repoFullName), + listPullRequests(env, repoFullName), + listRecentMergedPullRequests(env, repoFullName), + ]); + const [confirmedMinerLogins, checkSummariesByPullNumber] = await Promise.all([ + loadCachedConfirmedMinerLogins(env, pullRequests), + loadQueueCheckSummariesByPullNumber(env, repoFullName, pullRequests), + ]); + return buildMaintainerQueueDigest({ + repo, + issues, + pullRequests, + recentMergedPullRequests, + confirmedMinerLogins, + checkSummariesByPullNumber, + controlPanelUrl: maintainerControlPanelUrl(env, repoFullName), + }); +} + +async function loadCachedConfirmedMinerLogins(env: Env, pullRequests: Awaited>): Promise { + const logins = [ + ...new Set( + pullRequests + .filter((pr) => pr.state === "open") + .flatMap((pr) => (pr.authorLogin ? [pr.authorLogin] : [])) + .map((login) => login.toLowerCase()), + ), + ].slice(0, 50); + const detections = await Promise.all(logins.map(async (login) => [login, await getFreshOfficialMinerDetection(env, login)] as const)); + return detections.flatMap(([login, detection]) => (detection?.status === "confirmed" ? [login] : [])); +} + +async function loadQueueCheckSummariesByPullNumber( + env: Env, + repoFullName: string, + pullRequests: Awaited>, +): Promise>>> { + const openPullRequests = pullRequests.filter((pr) => pr.state === "open").slice(0, 50); + const entries = await Promise.all(openPullRequests.map(async (pr) => [pr.number, await listCheckSummaries(env, repoFullName, pr.number)] as const)); + return Object.fromEntries(entries); +} + +function maintainerControlPanelUrl(env: Env, repoFullName: string): string | null { + const origin = env.PUBLIC_SITE_ORIGIN ?? "https://gittensory.aethereal.dev"; + try { + const url = new URL("/app", origin); + url.searchParams.set("view", "maintainer"); + url.searchParams.set("repo", repoFullName); + return url.toString(); + } catch { + return null; + } +} + +async function recordAgentCommandFeedbackPrompt( + env: Env, + args: { + deliveryId: string; + command: string; + actor: string; + targetKey: string; + actorKind: "maintainer" | "author"; + family: "agent_command" | "maintainer_digest"; + }, +): Promise { + await recordAuditEvent(env, { + eventType: "github_app.agent_command_feedback_prompted", + actor: args.actor, + targetKey: args.targetKey, + outcome: "completed", + detail: args.command, + metadata: { + deliveryId: args.deliveryId, + command: args.command, + actorKind: args.actorKind, + family: args.family, + scoringImpact: "none", + }, + }); +} + async function auditPrVisibilitySkip( env: Env, repoFullName: string, diff --git a/test/unit/github-commands.test.ts b/test/unit/github-commands.test.ts index 7cdb1229e7..fc961023e6 100644 --- a/test/unit/github-commands.test.ts +++ b/test/unit/github-commands.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { + buildMaintainerQueueDigest, buildPublicAgentCommandComment, isAuthorizedCommandActor, + isMaintainerOnlyCommand, parseGittensoryMentionCommand, sanitizePublicComment, } from "../../src/github/commands"; @@ -15,8 +17,15 @@ describe("GitHub mention commands", () => { expect(parseGittensoryMentionCommand("@gittensory reviewability")?.name).toBe("reviewability"); expect(parseGittensoryMentionCommand("@gittensory repo-fit")?.name).toBe("repo-fit"); expect(parseGittensoryMentionCommand("@gittensory packet")?.name).toBe("packet"); + expect(parseGittensoryMentionCommand("@gittensory queue-summary")?.name).toBe("queue-summary"); + expect(parseGittensoryMentionCommand("@gittensory confirmed-miners")?.name).toBe("confirmed-miners"); + expect(parseGittensoryMentionCommand("@gittensory review-now")?.name).toBe("review-now"); + expect(parseGittensoryMentionCommand("@gittensory needs-author")?.name).toBe("needs-author"); + expect(parseGittensoryMentionCommand("@gittensory duplicate-clusters")?.name).toBe("duplicate-clusters"); expect(parseGittensoryMentionCommand("@gittensory unknown")?.name).toBe("help"); expect(parseGittensoryMentionCommand("gittensory preflight")).toBeNull(); + expect(isMaintainerOnlyCommand("queue-summary")).toBe(true); + expect(isMaintainerOnlyCommand("preflight")).toBe(false); }); it("authorizes maintainers and confirmed miner PR authors only", () => { @@ -48,6 +57,22 @@ describe("GitHub mention commands", () => { officialAuthorDetection: { status: "not_found" }, }), ).toMatchObject({ authorized: false, reason: "pr_author_not_confirmed_miner" }); + expect( + isAuthorizedCommandActor({ + commandName: "queue-summary", + commenterLogin: "oktofeesh1", + commenterAssociation: "NONE", + pullRequestAuthorLogin: "oktofeesh1", + officialAuthorDetection: { status: "confirmed", snapshot: minerSnapshot() }, + }), + ).toMatchObject({ authorized: false, reason: "maintainer_command_requires_maintainer" }); + expect( + isAuthorizedCommandActor({ + commandName: "queue-summary", + commenterLogin: "reviewer", + commenterAssociation: "MEMBER", + }), + ).toMatchObject({ authorized: true, reason: "maintainer_invocation" }); expect( isAuthorizedCommandActor({ commenterLogin: "other", @@ -113,6 +138,7 @@ describe("GitHub mention commands", () => { /wallet|hotkey|payout|reviewability|private ranking/i, ); expect(sanitizePublicComment("public score estimate and scoreability should stay private")).not.toMatch(/public score estimate|scoreability/i); + expect(sanitizePublicComment("public score estimate private scoreability context score preview")).not.toMatch(/public score estimate|scoreability|score preview/i); expect(sanitizePublicComment("Command: @gittensory reviewability")).toContain("@gittensory reviewability"); expect(sanitizePublicComment("private ranking, wallet, payout")).toBe("private context"); }); @@ -357,6 +383,16 @@ describe("GitHub mention commands", () => { expect(noBundle).toContain("**Preflight summary**"); expect(noBundle).toContain("No public-safe context is available"); + const noBundleNextAction = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory next-action")!, + repo: null, + issue: { number: 48, title: "PR", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "author", + }); + expect(noBundleNextAction).toContain("**Recommended next step**"); + expect(noBundleNextAction).toContain("No public-safe context is available"); + const emptyBlockers = buildPublicAgentCommandComment({ command: parseGittensoryMentionCommand("@gittensory blockers")!, repo: null, @@ -387,6 +423,15 @@ describe("GitHub mention commands", () => { }); expect(emptyDuplicate).toContain("No duplicate or work-in-progress collision signal is visible"); + const missingDigest = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory queue-summary")!, + repo: null, + issue: { number: 47, title: "PR", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "maintainer", + }); + expect(missingDigest).toContain("Cached queue context is unavailable"); + const withPrFallbackScope = buildPublicAgentCommandComment({ command: parseGittensoryMentionCommand("@gittensory next-action")!, repo: null, @@ -459,6 +504,35 @@ describe("GitHub mention commands", () => { expect(blockersWithFallbackLabel).toContain("custom signal code"); expect(blockersWithFallbackLabel).toContain("Reduce concurrent review load."); + const blockersWithDuplicateCodes = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory blockers")!, + repo: null, + issue: { number: 24, title: "PR", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "maintainer", + bundle: { + run: completedRun("run-blockers-duplicate-codes"), + actions: [ + { + id: "blocker-duplicate-codes", + runId: "run-blockers-duplicate-codes", + actionType: "explain_score_blockers", + status: "blocked", + recommendation: "Wait for review capacity", + why: [], + blockedBy: ["open_pr_pressure", "open_pr_pressure"], + publicSafeSummary: "Reduce concurrent review load.", + approvalRequired: true, + safetyClass: "private", + payload: {}, + }, + ], + contextSnapshots: [], + summary: "blockers", + }, + }); + expect(blockersWithDuplicateCodes.match(/Open pull request queue pressure/g)).toHaveLength(1); + const duplicateViaRecommendation = buildPublicAgentCommandComment({ command: parseGittensoryMentionCommand("@gittensory duplicate-check")!, repo: null, @@ -802,6 +876,122 @@ describe("GitHub mention commands", () => { }); expect(duplicateBlockers.match(/Open pull request queue pressure/g)).toHaveLength(1); }); + + it("builds maintainer-only queue digests with safe routing, sorting, and private-detail pointers", () => { + const digest = sampleMaintainerDigest(); + expect(digest.totals.confirmedMinerPullRequests).toBe(2); + expect(digest.reviewNowPullRequests.map((pr) => pr.number)).toEqual([10, 11]); + expect(digest.needsAuthorPullRequests[0]?.signals).toContain("duplicate_or_overlap"); + expect(digest.needsAuthorPullRequests.map((pr) => pr.number)).toEqual(expect.arrayContaining([12, 13, 14, 15])); + expect(digest.duplicateClusters.length).toBeGreaterThan(0); + + const reversedDigest = sampleMaintainerDigest({ reversePullRequests: true }); + expect(reversedDigest.reviewNowPullRequests.map((pr) => pr.number)).toEqual(digest.reviewNowPullRequests.map((pr) => pr.number)); + expect(reversedDigest.needsAuthorPullRequests.map((pr) => pr.number)).toEqual(digest.needsAuthorPullRequests.map((pr) => pr.number)); + + const queueSummary = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory queue-summary")!, + repo: { fullName: "owner/repo" } as any, + issue: { number: 99, title: "Digest", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "maintainer", + maintainerDigest: digest, + }); + expect(queueSummary).toContain("### Gittensory maintainer queue summary"); + expect(queueSummary).toContain("**Queue summary**"); + expect(queueSummary).toContain("Authenticated control panel: https://gittensory.test/app?view=maintainer&repo=owner%2Frepo"); + expect(queueSummary).toContain("Feedback on this response is tracked separately"); + expect(queueSummary).not.toMatch(/wallet|hotkey|raw trust score|payout|reward estimate|farming|private reviewability|public score estimate/i); + + const confirmed = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory confirmed-miners")!, + repo: { fullName: "owner/repo" } as any, + issue: { number: 99, title: "Digest", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "maintainer", + maintainerDigest: digest, + }); + expect(confirmed).toContain("**Confirmed-miner PRs**"); + expect(confirmed).toContain("#10: Ready linked fix"); + expect(confirmed).toContain("#13: Cache overlap first"); + + const reviewNow = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory review-now")!, + repo: { fullName: "owner/repo" } as any, + issue: { number: 99, title: "Digest", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "maintainer", + maintainerDigest: digest, + }); + expect(reviewNow).toContain("**Review-now candidates**"); + expect(reviewNow).toContain("#10: Ready linked fix"); + expect(reviewNow).not.toContain("#12: Needs issue context"); + + const needsAuthor = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory needs-author")!, + repo: { fullName: "owner/repo" } as any, + issue: { number: 99, title: "Digest", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "maintainer", + maintainerDigest: digest, + }); + expect(needsAuthor).toContain("**Needs-author queue**"); + expect(needsAuthor).toContain("Missing linked issue or no-issue rationale."); + expect(needsAuthor).toContain("1 cached check(s) need attention."); + expect(needsAuthor).toContain("Possible duplicate or WIP overlap"); + + const duplicateClusters = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory duplicate-clusters")!, + repo: { fullName: "owner/repo" } as any, + issue: { number: 99, title: "Digest", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "maintainer", + maintainerDigest: digest, + }); + expect(duplicateClusters).toContain("**Duplicate/WIP clusters**"); + expect(duplicateClusters).toContain("risk:"); + + const defensiveDigest = buildMaintainerQueueDigest({ + repo: null, + issues: [issue(5, "Medium overlap issue")], + pullRequests: [ + { ...pr(16, "Long medium overlap implementation title that should be shortened in public queue output because it exceeds the digest line budget", "gina", { linkedIssues: [5], updatedAt: "not-a-date" }) }, + { repoFullName: "owner/repo", number: 17, title: "No timestamp review candidate", state: "open", authorLogin: "hal", authorAssociation: "NONE", labels: [], linkedIssues: [6], body: "Fixes #6" }, + { repoFullName: "owner/repo", number: 18, title: "Maintainer draft stewardship", state: "open", authorLogin: "ivy", authorAssociation: "OWNER", isDraft: true, labels: [], linkedIssues: [7], body: "Fixes #7" }, + ], + recentMergedPullRequests: [ + { + repoFullName: "owner/repo", + number: 200, + title: "Long medium overlap implementation title that should be shortened in public queue output because it exceeds the digest line budget", + labels: [], + linkedIssues: [5], + changedFiles: [], + payload: {}, + }, + ], + }); + const defensiveClusters = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory duplicate-clusters")!, + repo: null, + issue: { number: 100, title: "Digest", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "maintainer", + maintainerDigest: defensiveDigest, + }); + expect(defensiveClusters).toContain("medium risk:"); + expect(defensiveClusters).toContain("..."); + const defensiveSummary = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory queue-summary")!, + repo: null, + issue: { number: 101, title: "Digest", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "maintainer", + maintainerDigest: defensiveDigest, + }); + expect(defensiveSummary).toContain("Use the authenticated maintainer dashboard and private API"); + expect(defensiveDigest.needsAuthorPullRequests.find((pr) => pr.number === 18)?.reasons).toContain("Maintainer-authored PR; review as repo stewardship."); + }); }); function completedRun(id: string) { @@ -1040,3 +1230,57 @@ function minerSnapshot() { issueLabels: [], }; } + +function sampleMaintainerDigest(options: { reversePullRequests?: boolean } = {}) { + const pullRequests = [ + pr(10, "Ready linked fix", "alice", { linkedIssues: [1], updatedAt: "2099-01-01T00:00:00.000Z" }), + pr(11, "Documentation reference update", "bob", { linkedIssues: [2], updatedAt: "2099-01-01T00:00:00.000Z" }), + pr(12, "Needs issue context", "carol", { linkedIssues: [], updatedAt: "2099-01-01T00:00:00.000Z" }), + pr(13, "Cache overlap first", "dave", { linkedIssues: [3], updatedAt: "2099-01-01T00:00:00.000Z" }), + pr(14, "Cache overlap second", "erin", { linkedIssues: [3], updatedAt: "2099-01-01T00:00:00.000Z" }), + pr(15, "Legacy cleanup request", "frank", { linkedIssues: [4], updatedAt: "2020-01-01T00:00:00.000Z" }), + ]; + return buildMaintainerQueueDigest({ + repo: { fullName: "owner/repo", isRegistered: true, registryConfig: { emissionShare: 0.1, issueDiscoveryShare: 0, labelMultipliers: {}, maintainerCut: 0, raw: {}, repo: "owner/repo" } } as any, + issues: [ + issue(1, "Ready linked fix"), + issue(2, "Documentation reference update"), + issue(3, "Cache overlap issue"), + issue(4, "Legacy cleanup request"), + ], + pullRequests: options.reversePullRequests ? [...pullRequests].reverse() : pullRequests, + confirmedMinerLogins: ["alice", "dave"], + checkSummariesByPullNumber: { + 12: [{ id: "check-12", repoFullName: "owner/repo", pullNumber: 12, name: "validate", status: "completed", conclusion: "failure", payload: {} }], + }, + controlPanelUrl: "https://gittensory.test/app?view=maintainer&repo=owner%2Frepo", + }); +} + +function pr(number: number, title: string, authorLogin: string, options: { linkedIssues: number[]; updatedAt: string }) { + return { + repoFullName: "owner/repo", + number, + title, + state: "open", + authorLogin, + authorAssociation: "NONE", + updatedAt: options.updatedAt, + createdAt: options.updatedAt, + labels: [], + linkedIssues: options.linkedIssues, + body: options.linkedIssues.map((issueNumber) => `Fixes #${issueNumber}`).join("\n"), + }; +} + +function issue(number: number, title: string) { + return { + repoFullName: "owner/repo", + number, + title, + state: "open", + authorLogin: "reporter", + labels: [], + linkedPrs: [], + }; +} diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 7b9928affd..2f33625404 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -16,6 +16,8 @@ import { listSignalSnapshots, persistSignalSnapshot, recordProductUsageEvent, + upsertOfficialMinerDetection, + upsertIssueFromGitHub, upsertRepoSyncSegment, upsertInstallation, upsertPullRequestFromGitHub, @@ -1541,6 +1543,102 @@ describe("queue processors", () => { expect(JSON.stringify(usageEvents)).not.toMatch(/wallet|hotkey|raw trust|deliveryId|installation-token/i); }); + it("posts maintainer-only queue digest commands from cached public-safe metadata", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + for (const issue of [ + { number: 1, title: "Ready linked fix" }, + { number: 2, title: "Overlap issue" }, + ]) { + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { + number: issue.number, + title: issue.title, + state: "open", + user: { login: "reporter" }, + labels: [], + body: "", + }); + } + for (const pull of [ + { number: 90, title: "Ready linked fix", user: { login: "alice" }, body: "Fixes #1" }, + { number: 91, title: "Needs author context", user: { login: "bob" }, body: "" }, + { number: 92, title: "Overlap route first", user: { login: "carol" }, body: "Fixes #2" }, + { number: 93, title: "Overlap route second", user: { login: "dana" }, body: "Fixes #2" }, + ]) { + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + ...pull, + state: "open", + author_association: "NONE", + labels: [], + }); + } + await upsertOfficialMinerDetection(env, "alice", { status: "confirmed", snapshot: queueMinerSnapshot("alice") }, 60_000); + + const calls = { commentsCreated: 0, token: 0 }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) { + calls.token += 1; + return Response.json({ token: "installation-token" }); + } + if (url.includes("/issues/90/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/90/comments") && method === "POST") { + calls.commentsCreated += 1; + const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string }; + expect(body.body).toContain("### Gittensory maintainer queue summary"); + expect(body.body).toContain("Open PRs: 4"); + expect(body.body).toContain("confirmed-miner PRs: 1"); + expect(body.body).toContain("Authenticated control panel: https://gittensory.aethereal.dev/app?view=maintainer&repo=JSONbored%2Fgittensory"); + expect(body.body).not.toMatch(/wallet|hotkey|raw trust score|payout|reward estimate|farming|private reviewability|public score estimate/i); + return Response.json({ id: 1001 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "maintainer-queue-summary", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 90, title: "Ready linked fix", state: "open", pull_request: {}, user: { login: "alice" }, author_association: "NONE" }, + comment: { + id: 9001, + body: "@gittensory queue-summary", + user: { login: "maintainer", type: "User" }, + author_association: "OWNER", + }, + }, + }); + + expect(calls).toEqual({ commentsCreated: 1, token: 1 }); + const audit = await env.DB.prepare("select event_type, detail, metadata_json from audit_events where target_key = ? order by created_at") + .bind("JSONbored/gittensory#90") + .all<{ event_type: string; detail: string | null; metadata_json: string }>(); + expect(audit.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ event_type: "github_app.agent_command_replied" }), + expect.objectContaining({ event_type: "github_app.agent_command_feedback_prompted", detail: "queue-summary" }), + ]), + ); + expect(audit.results.find((entry) => entry.event_type === "github_app.agent_command_feedback_prompted")?.metadata_json).toContain("maintainer_digest"); + const usage = await env.DB.prepare("select payload_json from signal_snapshots where signal_type = ? and target_key = ?") + .bind("github-agent-command-usage", "JSONbored/gittensory#90") + .all<{ payload_json: string }>(); + const usagePayload = JSON.parse(usage.results[0]?.payload_json ?? "{}") as { command?: string; outcome?: string; family?: string; actorHash?: string }; + expect(usagePayload).toEqual(expect.objectContaining({ command: "queue-summary", outcome: "replied", family: "maintainer_digest" })); + expect(usagePayload.actorHash).toMatch(/^[a-f0-9]{64}$/); + expect(JSON.stringify(usagePayload)).not.toContain('"actor":'); + const usageEvents = await listProductUsageEvents(env, { limit: 5 }); + expect(usageEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ surface: "github_app", eventName: "agent_command_replied", outcome: "completed", metadata: expect.objectContaining({ family: "queue_digest" }) }), + ]), + ); + }); + it("skips unauthorized, bot, and non-PR @gittensory mention commands without public output", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); let commentCalls = 0; @@ -1618,12 +1716,22 @@ describe("queue processors", () => { comment: { id: 4, body: "@gittensory preflight", user: { login: "commenter", type: "User" } }, }, }); + await processJob(env, { + type: "github-webhook", + deliveryId: "agent-command-maintainer-only-denied", + eventName: "issue_comment", + payload: { + ...basePayload, + issue: { number: 84, title: "Maintainer digest PR", state: "open", pull_request: {}, user: { login: "not-a-miner" }, author_association: "NONE" }, + comment: { id: 5, body: "@gittensory queue-summary", user: { login: "not-a-miner", type: "User" }, author_association: "NONE" }, + }, + }); expect(commentCalls).toBe(0); const skips = await env.DB.prepare("select detail from audit_events where event_type = ? order by detail") .bind("github_app.agent_command_skipped") .all<{ detail: string }>(); - expect(skips.results.map((entry) => entry.detail)).toEqual(expect.arrayContaining(["bot_author", "not_a_pull_request_thread", "pr_author_not_confirmed_miner"])); + expect(skips.results.map((entry) => entry.detail)).toEqual(expect.arrayContaining(["bot_author", "maintainer_command_requires_maintainer", "not_a_pull_request_thread", "pr_author_not_confirmed_miner"])); const usageEvents = await listProductUsageEvents(env, { limit: 10 }); expect(usageEvents).toEqual( expect.arrayContaining([ @@ -1717,6 +1825,38 @@ function withProductUsageInsertFailure(env: Env): Env { }; } +function queueMinerSnapshot(login: string) { + return { + source: "gittensor_api" as const, + githubId: `id-${login}`, + githubUsername: login, + isEligible: true, + credibility: 1, + eligibleRepoCount: 1, + issueDiscoveryScore: 0, + issueTokenScore: 0, + issueCredibility: 1, + isIssueEligible: false, + issueEligibleRepoCount: 0, + alphaPerDay: 0, + taoPerDay: 0, + usdPerDay: 0, + totals: { + pullRequests: 1, + mergedPullRequests: 1, + openPullRequests: 1, + closedPullRequests: 0, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + }, + repositories: [], + pullRequests: [], + issueLabels: [], + }; +} + async function generatePrivateKeyPem(): Promise { const key = (await crypto.subtle.generateKey( {