Skip to content

Commit f00b477

Browse files
AlbinoGeekclaude
andcommitted
feat(repo_status): multi-repo dashboard tool
Batches default branch HEAD, CI status, open PR/issue counts, latest commit, and optional local git state across up to 20 repos in a single MCP call. Uses parallel GraphQL queries (concurrency 4). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0db14c0 commit f00b477

1 file changed

Lines changed: 240 additions & 0 deletions

File tree

src/server/repo-status-tool.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { execFileSync } from "node:child_process";
2+
3+
import type { FastMCP } from "fastmcp";
4+
import { z } from "zod";
5+
import { gateAuth } from "./github-auth.js";
6+
import { graphqlQuery, parallelApi, resolveLocalRepoRemote } from "./github-client.js";
7+
import { jsonRespond, truncateText } from "./json.js";
8+
import { FormatSchema, LocalOrRemoteRepoSchema } from "./schemas.js";
9+
10+
interface StatusCheckNode {
11+
name?: string;
12+
conclusion?: string;
13+
context?: string;
14+
state?: string;
15+
}
16+
17+
interface RepoQueryResult {
18+
repository: {
19+
defaultBranchRef: {
20+
name: string;
21+
target: {
22+
oid: string;
23+
messageHeadline: string;
24+
author: { user: { login: string } | null; date: string };
25+
statusCheckRollup: {
26+
state: string;
27+
contexts: { nodes: StatusCheckNode[] };
28+
} | null;
29+
};
30+
} | null;
31+
openPRs: { totalCount: number };
32+
draftPRs: { nodes: { isDraft: boolean }[] };
33+
openIssues: { totalCount: number };
34+
};
35+
}
36+
37+
interface RepoResult {
38+
owner: string;
39+
repo: string;
40+
defaultBranch?: string;
41+
latestCommit?: { sha7: string; message: string; author: string; date: string };
42+
ci?: { status: string; failedChecks?: { name: string; conclusion: string }[] };
43+
openPRs?: number;
44+
draftPRs?: number;
45+
openIssues?: number;
46+
local?: { branch: string; dirty: number; ahead: number; behind: number };
47+
error?: string;
48+
}
49+
50+
function timeAgo(dateStr: string): string {
51+
const sec = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
52+
if (sec < 60) return "now";
53+
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
54+
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
55+
if (sec < 604800) return `${Math.floor(sec / 86400)}d ago`;
56+
return `${Math.floor(sec / 604800)}w ago`;
57+
}
58+
59+
function getLocalGitState(localPath: string): RepoResult["local"] | undefined {
60+
try {
61+
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
62+
cwd: localPath,
63+
encoding: "utf8",
64+
}).trim();
65+
66+
const dirtyOut = execFileSync("git", ["status", "--short"], {
67+
cwd: localPath,
68+
encoding: "utf8",
69+
});
70+
const dirty = dirtyOut.split("\n").filter((l) => l.trim()).length;
71+
72+
let ahead = 0;
73+
let behind = 0;
74+
try {
75+
const aheadStr = execFileSync("git", ["rev-list", "--count", "@{upstream}..HEAD"], {
76+
cwd: localPath,
77+
encoding: "utf8",
78+
}).trim();
79+
const behindStr = execFileSync("git", ["rev-list", "--count", "HEAD..@{upstream}"], {
80+
cwd: localPath,
81+
encoding: "utf8",
82+
}).trim();
83+
ahead = Number.parseInt(aheadStr, 10) || 0;
84+
behind = Number.parseInt(behindStr, 10) || 0;
85+
} catch {
86+
// no upstream configured
87+
}
88+
89+
return { branch, dirty, ahead, behind };
90+
} catch {
91+
return undefined;
92+
}
93+
}
94+
95+
const REPO_STATUS_QUERY = `
96+
query RepoStatus($owner: String!, $name: String!) {
97+
repository(owner: $owner, name: $name) {
98+
defaultBranchRef {
99+
name
100+
target {
101+
... on Commit {
102+
oid
103+
messageHeadline
104+
author { user { login } date }
105+
statusCheckRollup {
106+
state
107+
contexts(first: 20) {
108+
nodes {
109+
... on CheckRun { name conclusion }
110+
... on StatusContext { context state }
111+
}
112+
}
113+
}
114+
}
115+
}
116+
}
117+
openPRs: pullRequests(states: OPEN) { totalCount }
118+
draftPRs: pullRequests(states: OPEN, first: 100) { nodes { isDraft } }
119+
openIssues: issues(states: OPEN) { totalCount }
120+
}
121+
}`;
122+
123+
export function registerRepoStatusTool(server: FastMCP): void {
124+
server.addTool({
125+
name: "repo_status",
126+
description:
127+
"Multi-repo dashboard: default branch HEAD, CI status, open PR/issue counts, " +
128+
"latest commit. Accepts multiple repos in one call. Optionally includes local " +
129+
"git state when a localPath is provided.",
130+
annotations: { readOnlyHint: true },
131+
parameters: z.object({
132+
repos: z.array(LocalOrRemoteRepoSchema).min(1).max(20).describe("Repos to query."),
133+
format: FormatSchema,
134+
}),
135+
execute: async (args) => {
136+
const auth = gateAuth();
137+
if (!auth.ok) return jsonRespond(auth.body);
138+
139+
const results = await parallelApi(args.repos, async (repoRef) => {
140+
let owner: string;
141+
let repo: string;
142+
let localState: RepoResult["local"] | undefined;
143+
144+
if ("localPath" in repoRef) {
145+
const resolved = resolveLocalRepoRemote(repoRef.localPath);
146+
if (!resolved) {
147+
return { owner: "unknown", repo: repoRef.localPath, error: "local_repo_no_remote" };
148+
}
149+
owner = resolved.owner;
150+
repo = resolved.repo;
151+
localState = getLocalGitState(repoRef.localPath);
152+
} else {
153+
owner = repoRef.owner;
154+
repo = repoRef.repo;
155+
}
156+
157+
try {
158+
const data = await graphqlQuery<RepoQueryResult>(REPO_STATUS_QUERY, {
159+
owner,
160+
name: repo,
161+
});
162+
const r = data.repository;
163+
const result: RepoResult = { owner, repo };
164+
165+
if (r.defaultBranchRef) {
166+
result.defaultBranch = r.defaultBranchRef.name;
167+
const c = r.defaultBranchRef.target;
168+
result.latestCommit = {
169+
sha7: c.oid.substring(0, 7),
170+
message: truncateText(c.messageHeadline, 60),
171+
author: c.author.user?.login ?? "unknown",
172+
date: timeAgo(c.author.date),
173+
};
174+
175+
const rollup = c.statusCheckRollup;
176+
if (rollup) {
177+
const failed = rollup.contexts.nodes
178+
.filter((n) => {
179+
if (n.conclusion) return !["SUCCESS", "SKIPPED"].includes(n.conclusion);
180+
if (n.state) return n.state !== "SUCCESS";
181+
return false;
182+
})
183+
.map((n) => ({
184+
name: n.name ?? n.context ?? "unknown",
185+
conclusion: n.conclusion ?? n.state ?? "unknown",
186+
}));
187+
result.ci = {
188+
status: rollup.state.toLowerCase(),
189+
...(failed.length > 0 ? { failedChecks: failed } : {}),
190+
};
191+
}
192+
}
193+
194+
result.openPRs = r.openPRs.totalCount;
195+
result.draftPRs = r.draftPRs.nodes.filter((n) => n.isDraft).length;
196+
result.openIssues = r.openIssues.totalCount;
197+
if (localState) result.local = localState;
198+
199+
return result;
200+
} catch {
201+
return { owner, repo, error: "not_found" };
202+
}
203+
});
204+
205+
if (args.format === "json") return jsonRespond({ repos: results });
206+
207+
const md = results
208+
.map((r) => {
209+
if (r.error) return `## ${r.owner}/${r.repo}\nError: ${r.error}`;
210+
const lines: string[] = [];
211+
lines.push(`## ${r.owner}/${r.repo} (${r.defaultBranch ?? "?"})`);
212+
if (r.latestCommit) {
213+
lines.push(
214+
`Latest: \`${r.latestCommit.sha7}\` ${r.latestCommit.message}` +
215+
` — ${r.latestCommit.author}, ${r.latestCommit.date}`,
216+
);
217+
}
218+
if (r.ci) {
219+
const icon = r.ci.status === "success" ? "✓ passing" : "✗ failing";
220+
const extra = r.ci.failedChecks?.map((c) => c.name).join(", ");
221+
lines.push(`CI: ${icon}${extra ? `: ${extra}` : ""}`);
222+
} else {
223+
lines.push("CI: not configured");
224+
}
225+
const draft = r.draftPRs ? ` (${r.draftPRs} draft)` : "";
226+
lines.push(`PRs: ${r.openPRs ?? 0} open${draft} · Issues: ${r.openIssues ?? 0} open`);
227+
if (r.local) {
228+
lines.push(
229+
`[Local: ${r.local.branch}, ${r.local.dirty} dirty, ` +
230+
`${r.local.ahead} ahead / ${r.local.behind} behind]`,
231+
);
232+
}
233+
return lines.join("\n");
234+
})
235+
.join("\n\n");
236+
237+
return md;
238+
},
239+
});
240+
}

0 commit comments

Comments
 (0)