Skip to content

Commit 27affb2

Browse files
AlbinoGeekclaude
andcommitted
feat(my_work): cross-repo personal work queue
Single GraphQL query fetches authored PRs (with CI + review state), PRs awaiting review, and assigned issues across all repos. Replaces multiple GitHub notification round-trips. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f00b477 commit 27affb2

File tree

1 file changed

+198
-0
lines changed

1 file changed

+198
-0
lines changed

src/server/my-work-tool.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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, truncateText } from "./json.js";
6+
import { FormatSchema } from "./schemas.js";
7+
8+
interface GraphQLPullRequest {
9+
__typename: "PullRequest";
10+
number: number;
11+
title: string;
12+
isDraft: boolean;
13+
updatedAt: string;
14+
repository: { nameWithOwner: string };
15+
author: { login: string };
16+
reviewDecision: string | null;
17+
commits: { nodes: { commit: { statusCheckRollup: { state: string } | null } }[] };
18+
}
19+
20+
interface GraphQLIssue {
21+
__typename: "Issue";
22+
number: number;
23+
title: string;
24+
updatedAt: string;
25+
repository: { nameWithOwner: string };
26+
labels: { nodes: { name: string }[] };
27+
}
28+
29+
type SearchNode = GraphQLPullRequest | GraphQLIssue;
30+
31+
interface SearchResponse {
32+
authored: { nodes: SearchNode[] };
33+
reviewRequested: { nodes: SearchNode[] };
34+
assignedIssues: { nodes: SearchNode[] };
35+
}
36+
37+
function relativeTime(iso: string): string {
38+
const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
39+
if (sec < 60) return "now";
40+
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
41+
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
42+
if (sec < 604800) return `${Math.floor(sec / 86400)}d ago`;
43+
return `${Math.floor(sec / 604800)}w ago`;
44+
}
45+
46+
export function registerMyWorkTool(server: FastMCP): void {
47+
server.addTool({
48+
name: "my_work",
49+
description:
50+
"Cross-repo personal work queue: your open PRs (with CI + review state), " +
51+
"PRs awaiting your review, and assigned issues. One call replaces browsing " +
52+
"GitHub notifications.",
53+
annotations: { readOnlyHint: true },
54+
parameters: z.object({
55+
username: z.string().optional().describe("GitHub username. Defaults to authenticated user."),
56+
maxResults: z.number().int().min(1).max(100).optional().default(30),
57+
format: FormatSchema,
58+
}),
59+
execute: async (args) => {
60+
const auth = gateAuth();
61+
if (!auth.ok) return jsonRespond(auth.body);
62+
63+
let username = args.username;
64+
if (!username) {
65+
try {
66+
const viewer = await graphqlQuery<{ viewer: { login: string } }>(
67+
"query { viewer { login } }",
68+
);
69+
username = viewer.viewer.login;
70+
} catch (e) {
71+
const msg = e instanceof Error ? e.message : String(e);
72+
return jsonRespond({ error: "graphql_error", message: msg });
73+
}
74+
}
75+
76+
const max = args.maxResults;
77+
const query = `query {
78+
authored: search(query: "is:pr is:open author:${username}", type: ISSUE, first: ${max}) {
79+
nodes {
80+
... on PullRequest {
81+
__typename number title isDraft updatedAt
82+
repository { nameWithOwner }
83+
author { login }
84+
reviewDecision
85+
commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
86+
}
87+
}
88+
}
89+
reviewRequested: search(query: "is:pr is:open review-requested:${username}", type: ISSUE, first: ${max}) {
90+
nodes {
91+
... on PullRequest {
92+
__typename number title updatedAt
93+
repository { nameWithOwner }
94+
author { login }
95+
}
96+
}
97+
}
98+
assignedIssues: search(query: "is:issue is:open assignee:${username}", type: ISSUE, first: ${max}) {
99+
nodes {
100+
... on Issue {
101+
__typename number title updatedAt
102+
repository { nameWithOwner }
103+
labels(first: 5) { nodes { name } }
104+
}
105+
}
106+
}
107+
}`;
108+
109+
try {
110+
const data = await graphqlQuery<SearchResponse>(query);
111+
112+
const authoredPrs = data.authored.nodes
113+
.filter((n): n is GraphQLPullRequest => n.__typename === "PullRequest")
114+
.map((n) => ({
115+
repo: n.repository.nameWithOwner,
116+
number: n.number,
117+
title: truncateText(n.title, 80),
118+
draft: n.isDraft,
119+
ci: n.commits.nodes[0]?.commit.statusCheckRollup?.state ?? "NONE",
120+
reviewDecision: n.reviewDecision,
121+
updatedAt: n.updatedAt,
122+
}));
123+
124+
const reviewRequests = data.reviewRequested.nodes
125+
.filter((n): n is GraphQLPullRequest => n.__typename === "PullRequest")
126+
.map((n) => ({
127+
repo: n.repository.nameWithOwner,
128+
number: n.number,
129+
title: truncateText(n.title, 80),
130+
author: n.author.login,
131+
updatedAt: n.updatedAt,
132+
}));
133+
134+
const assignedIssues = data.assignedIssues.nodes
135+
.filter((n): n is GraphQLIssue => n.__typename === "Issue")
136+
.map((n) => ({
137+
repo: n.repository.nameWithOwner,
138+
number: n.number,
139+
title: truncateText(n.title, 80),
140+
labels: n.labels.nodes.map((l) => l.name),
141+
updatedAt: n.updatedAt,
142+
}));
143+
144+
const result = { username, authoredPrs, reviewRequests, assignedIssues };
145+
146+
if (args.format === "json") return jsonRespond(result);
147+
148+
// Markdown
149+
const lines: string[] = [`# My Work (@${username})`, ""];
150+
151+
lines.push(`## Authored PRs (${authoredPrs.length})`);
152+
if (authoredPrs.length === 0) {
153+
lines.push("No open PRs.");
154+
} else {
155+
for (const pr of authoredPrs) {
156+
const ci = pr.ci === "SUCCESS" ? "✓" : pr.ci === "FAILURE" ? "✗" : "⏳";
157+
const draft = pr.draft ? "[DRAFT] " : "";
158+
const review = pr.reviewDecision?.toLowerCase().replace(/_/g, " ") ?? "pending";
159+
lines.push(
160+
`- ${pr.repo}#${pr.number} ${draft}${pr.title}` +
161+
` — ${ci} CI, ${review}, ${relativeTime(pr.updatedAt)}`,
162+
);
163+
}
164+
}
165+
lines.push("");
166+
167+
lines.push(`## Review Requests (${reviewRequests.length})`);
168+
if (reviewRequests.length === 0) {
169+
lines.push("No review requests.");
170+
} else {
171+
for (const r of reviewRequests) {
172+
lines.push(
173+
`- ${r.repo}#${r.number} ${r.title} — by ${r.author}, ${relativeTime(r.updatedAt)}`,
174+
);
175+
}
176+
}
177+
lines.push("");
178+
179+
lines.push(`## Assigned Issues (${assignedIssues.length})`);
180+
if (assignedIssues.length === 0) {
181+
lines.push("No assigned issues.");
182+
} else {
183+
for (const iss of assignedIssues) {
184+
const labels = iss.labels.length > 0 ? ` (${iss.labels.join(", ")})` : "";
185+
lines.push(
186+
`- ${iss.repo}#${iss.number} ${iss.title}${labels}, ${relativeTime(iss.updatedAt)}`,
187+
);
188+
}
189+
}
190+
191+
return lines.join("\n");
192+
} catch (e) {
193+
const msg = e instanceof Error ? e.message : String(e);
194+
return jsonRespond({ error: "graphql_error", message: msg });
195+
}
196+
},
197+
});
198+
}

0 commit comments

Comments
 (0)