Skip to content

Commit d3b29d2

Browse files
AlbinoGeekclaude
andcommitted
feat(org_pulse): org-wide activity dashboard
Adds the `org_pulse` tool — a single GraphQL call that scans all recently-pushed repos in a GitHub org and surfaces what needs attention: failing CI on the default branch, stale open PRs (configurable age), and open PRs awaiting review. Repos are ranked by urgency so the LLM (or engineer) sees the hottest fires first. Replaces the need to enumerate repos manually (as repo_status requires) when answering "what's broken across the org right now?" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 798269a commit d3b29d2

File tree

2 files changed

+328
-0
lines changed

2 files changed

+328
-0
lines changed

src/server/org-pulse-tool.ts

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import type { FastMCP } from "fastmcp";
2+
import { z } from "zod";
3+
import { gateAuth } from "./github-auth.js";
4+
import { graphqlQuery } from "./github-client.js";
5+
import { jsonRespond } from "./json.js";
6+
import { FormatSchema } from "./schemas.js";
7+
8+
// ---------------------------------------------------------------------------
9+
// GraphQL types
10+
// ---------------------------------------------------------------------------
11+
12+
interface PullRequestNode {
13+
number: number;
14+
title: string;
15+
updatedAt: string;
16+
isDraft: boolean;
17+
author: { login: string } | null;
18+
reviewDecision: string | null;
19+
reviewRequests: { totalCount: number };
20+
}
21+
22+
interface RepoNode {
23+
name: string;
24+
nameWithOwner: string;
25+
pushedAt: string;
26+
isArchived: boolean;
27+
defaultBranchRef: {
28+
name: string;
29+
target: {
30+
statusCheckRollup: { state: string } | null;
31+
};
32+
} | null;
33+
pullRequests: {
34+
totalCount: number;
35+
nodes: PullRequestNode[];
36+
};
37+
issues: { totalCount: number };
38+
}
39+
40+
interface OrgPulseQueryResult {
41+
organization: {
42+
repositories: {
43+
nodes: RepoNode[];
44+
};
45+
} | null;
46+
}
47+
48+
// ---------------------------------------------------------------------------
49+
// Output types
50+
// ---------------------------------------------------------------------------
51+
52+
interface StalePRItem {
53+
number: number;
54+
title: string;
55+
author: string;
56+
daysSinceUpdate: number;
57+
}
58+
59+
interface UnreviewedPRItem {
60+
number: number;
61+
title: string;
62+
author: string;
63+
}
64+
65+
interface RepoAttentionItem {
66+
repo: string;
67+
ci: string;
68+
openPRs: number;
69+
openIssues: number;
70+
stalePRs: StalePRItem[];
71+
unreviewedPRs: UnreviewedPRItem[];
72+
lastPush: string;
73+
}
74+
75+
// ---------------------------------------------------------------------------
76+
// GraphQL query
77+
// ---------------------------------------------------------------------------
78+
79+
const ORG_PULSE_QUERY = `
80+
query OrgPulse($org: String!, $first: Int!) {
81+
organization(login: $org) {
82+
repositories(first: $first, orderBy: {field: PUSHED_AT, direction: DESC}, isArchived: false) {
83+
nodes {
84+
name
85+
nameWithOwner
86+
pushedAt
87+
isArchived
88+
defaultBranchRef {
89+
name
90+
target {
91+
... on Commit {
92+
statusCheckRollup { state }
93+
}
94+
}
95+
}
96+
pullRequests(states: OPEN, first: 10, orderBy: {field: UPDATED_AT, direction: DESC}) {
97+
totalCount
98+
nodes {
99+
number
100+
title
101+
updatedAt
102+
isDraft
103+
author { login }
104+
reviewDecision
105+
reviewRequests(first: 1) { totalCount }
106+
}
107+
}
108+
issues(states: OPEN) { totalCount }
109+
}
110+
}
111+
}
112+
}`;
113+
114+
// ---------------------------------------------------------------------------
115+
// Helpers
116+
// ---------------------------------------------------------------------------
117+
118+
function ciState(repo: RepoNode): string {
119+
const rollup = repo.defaultBranchRef?.target?.statusCheckRollup;
120+
if (!rollup) return "none";
121+
return rollup.state.toLowerCase();
122+
}
123+
124+
/** Higher score = more urgent. Used to sort the attention list. */
125+
function urgencyScore(item: RepoAttentionItem): number {
126+
return (
127+
(item.ci === "failure" ? 100 : 0) +
128+
item.stalePRs.length * 10 +
129+
item.unreviewedPRs.length * 5 +
130+
item.openPRs
131+
);
132+
}
133+
134+
// ---------------------------------------------------------------------------
135+
// Tool registration
136+
// ---------------------------------------------------------------------------
137+
138+
export function registerOrgPulseTool(server: FastMCP): void {
139+
server.addTool({
140+
name: "org_pulse",
141+
description:
142+
"Org-wide activity dashboard: scans all recently-active repos in a GitHub org and " +
143+
"surfaces actionable items — failing CI, stale PRs, unreviewed PRs. Answers " +
144+
"'what needs attention across the org?' in one call.",
145+
annotations: { readOnlyHint: true },
146+
parameters: z.object({
147+
org: z.string().describe("GitHub organization login."),
148+
maxRepos: z
149+
.number()
150+
.int()
151+
.min(1)
152+
.max(100)
153+
.optional()
154+
.default(30)
155+
.describe("Maximum number of repos to scan (ordered by most recently pushed)."),
156+
staleDays: z
157+
.number()
158+
.int()
159+
.min(1)
160+
.optional()
161+
.default(7)
162+
.describe("PRs with no activity for this many days are considered stale."),
163+
includeArchived: z
164+
.boolean()
165+
.optional()
166+
.default(false)
167+
.describe("Include archived repositories in the scan."),
168+
format: FormatSchema,
169+
}),
170+
execute: async (args) => {
171+
const auth = gateAuth();
172+
if (!auth.ok) return jsonRespond(auth.body);
173+
174+
const data = await graphqlQuery<OrgPulseQueryResult>(ORG_PULSE_QUERY, {
175+
org: args.org,
176+
first: args.maxRepos,
177+
});
178+
179+
if (!data.organization) {
180+
return jsonRespond({ error: "org_not_found", org: args.org });
181+
}
182+
183+
const allRepos = data.organization.repositories.nodes.filter(
184+
(r) => args.includeArchived || !r.isArchived,
185+
);
186+
187+
const now = Date.now();
188+
const attentionItems: RepoAttentionItem[] = [];
189+
const healthyRepoNames: string[] = [];
190+
191+
for (const repo of allRepos) {
192+
const ci = ciState(repo);
193+
const openPRNodes = repo.pullRequests.nodes;
194+
195+
const stalePRs: StalePRItem[] = openPRNodes
196+
.filter((pr) => {
197+
if (pr.isDraft) return false;
198+
const days = Math.floor((now - new Date(pr.updatedAt).getTime()) / 86_400_000);
199+
return days >= args.staleDays;
200+
})
201+
.map((pr) => ({
202+
number: pr.number,
203+
title: pr.title,
204+
author: pr.author?.login ?? "unknown",
205+
daysSinceUpdate: Math.floor((now - new Date(pr.updatedAt).getTime()) / 86_400_000),
206+
}));
207+
208+
const unreviewedPRs: UnreviewedPRItem[] = openPRNodes
209+
.filter(
210+
(pr) =>
211+
!pr.isDraft && pr.reviewDecision !== "APPROVED" && pr.reviewRequests.totalCount > 0,
212+
)
213+
.map((pr) => ({
214+
number: pr.number,
215+
title: pr.title,
216+
author: pr.author?.login ?? "unknown",
217+
}));
218+
219+
const needsAttention = ci === "failure" || stalePRs.length > 0 || unreviewedPRs.length > 0;
220+
221+
if (needsAttention) {
222+
attentionItems.push({
223+
repo: repo.nameWithOwner,
224+
ci,
225+
openPRs: repo.pullRequests.totalCount,
226+
openIssues: repo.issues.totalCount,
227+
stalePRs,
228+
unreviewedPRs,
229+
lastPush: repo.pushedAt,
230+
});
231+
} else {
232+
healthyRepoNames.push(repo.name);
233+
}
234+
}
235+
236+
attentionItems.sort((a, b) => urgencyScore(b) - urgencyScore(a));
237+
238+
const failingCI = attentionItems.filter((r) => r.ci === "failure").length;
239+
const totalStalePRs = attentionItems.reduce((n, r) => n + r.stalePRs.length, 0);
240+
const totalUnreviewedPRs = attentionItems.reduce((n, r) => n + r.unreviewedPRs.length, 0);
241+
const totalOpenPRs = allRepos.reduce((n, r) => n + r.pullRequests.totalCount, 0);
242+
const totalOpenIssues = allRepos.reduce((n, r) => n + r.issues.totalCount, 0);
243+
244+
const summary = {
245+
failingCI,
246+
stalePRs: totalStalePRs,
247+
unreviewedPRs: totalUnreviewedPRs,
248+
totalOpenPRs,
249+
totalOpenIssues,
250+
};
251+
252+
if (args.format === "json") {
253+
return jsonRespond({
254+
org: args.org,
255+
scannedRepos: allRepos.length,
256+
summary,
257+
attention: attentionItems,
258+
});
259+
}
260+
261+
// -----------------------------------------------------------------------
262+
// Markdown output
263+
// -----------------------------------------------------------------------
264+
265+
const lines: string[] = [];
266+
lines.push(`# Org Pulse: ${args.org}`);
267+
lines.push("");
268+
269+
const summaryParts = [
270+
`**${allRepos.length} repos scanned**`,
271+
`${failingCI} failing CI`,
272+
`${totalStalePRs} stale PRs`,
273+
`${totalUnreviewedPRs} unreviewed PRs`,
274+
];
275+
lines.push(summaryParts.join(" · "));
276+
277+
if (attentionItems.length > 0) {
278+
lines.push("");
279+
lines.push("## Needs Attention");
280+
281+
for (const item of attentionItems) {
282+
lines.push("");
283+
const ciLabel =
284+
item.ci === "failure"
285+
? "✗ CI failing"
286+
: item.ci === "success"
287+
? "✓ CI passing"
288+
: item.ci === "pending"
289+
? "⧗ CI pending"
290+
: "CI: none";
291+
lines.push(`### ${item.repo}${ciLabel}`);
292+
293+
const prDetail: string[] = [];
294+
if (item.stalePRs.length > 0) prDetail.push(`${item.stalePRs.length} stale`);
295+
if (item.unreviewedPRs.length > 0)
296+
prDetail.push(`${item.unreviewedPRs.length} unreviewed`);
297+
const prExtra = prDetail.length > 0 ? ` (${prDetail.join(", ")})` : "";
298+
lines.push(`- PRs: ${item.openPRs} open${prExtra}`);
299+
lines.push(`- Issues: ${item.openIssues} open`);
300+
301+
if (item.stalePRs.length > 0) {
302+
const staleStr = item.stalePRs
303+
.map((pr) => `#${pr.number} "${pr.title}" by ${pr.author} (${pr.daysSinceUpdate}d)`)
304+
.join(", ");
305+
lines.push(`- Stale: ${staleStr}`);
306+
}
307+
308+
if (item.unreviewedPRs.length > 0) {
309+
const reviewStr = item.unreviewedPRs
310+
.map((pr) => `#${pr.number} "${pr.title}" by ${pr.author}`)
311+
.join(", ");
312+
lines.push(`- Needs review: ${reviewStr}`);
313+
}
314+
}
315+
}
316+
317+
if (healthyRepoNames.length > 0) {
318+
lines.push("");
319+
lines.push(`## Healthy Repos (${healthyRepoNames.length})`);
320+
lines.push(healthyRepoNames.join(", "));
321+
}
322+
323+
return lines.join("\n");
324+
},
325+
});
326+
}

src/server/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { FastMCP } from "fastmcp";
22

33
import { registerCiDiagnosisTool } from "./ci-diagnosis-tool.js";
44
import { registerMyWorkTool } from "./my-work-tool.js";
5+
import { registerOrgPulseTool } from "./org-pulse-tool.js";
56
import { registerPrPreflightTool } from "./pr-preflight-tool.js";
67
import { registerReleaseReadinessTool } from "./release-readiness-tool.js";
78
import { registerRepoStatusTool } from "./repo-status-tool.js";
@@ -12,4 +13,5 @@ export function registerRethunkGitHubTools(server: FastMCP): void {
1213
registerPrPreflightTool(server);
1314
registerReleaseReadinessTool(server);
1415
registerCiDiagnosisTool(server);
16+
registerOrgPulseTool(server);
1517
}

0 commit comments

Comments
 (0)