standup-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Configuration resolved from environment variables.
3
+ *
4
+ * Design choice (same as the demo-first philosophy that makes adoption easy):
5
+ * the server NEVER hard-fails on missing credentials. Each source is wired up
6
+ * independently, and if NONE are configured (or STANDUP_DEMO_MODE=true) the
7
+ * server transparently runs on a synthetic multi-source dataset. So
8
+ * `npx standup-mcp --demo` produces a real standup in seconds with zero setup.
9
+ *
10
+ * Everything is read-only. No credential here grants write access, and the
11
+ * server makes no LLM calls of its own.
12
+ */
13
+ function clean(value) {
14
+ const trimmed = value?.trim();
15
+ return trimmed ? trimmed : undefined;
16
+ }
17
+ export function loadConfig(env = process.env) {
18
+ const displayName = clean(env.STANDUP_NAME);
19
+ const forceDemo = clean(env.STANDUP_DEMO_MODE)?.toLowerCase() === "true";
20
+ const githubToken = clean(env.GITHUB_TOKEN) ?? clean(env.GH_TOKEN);
21
+ const github = githubToken
22
+ ? { token: githubToken }
23
+ : undefined;
24
+ const jiraBase = clean(env.JIRA_BASE_URL)?.replace(/\/+$/, "");
25
+ const jiraEmail = clean(env.JIRA_EMAIL);
26
+ const jiraToken = clean(env.JIRA_API_TOKEN);
27
+ const jira = jiraBase && jiraEmail && jiraToken
28
+ ? { baseUrl: jiraBase, email: jiraEmail, apiToken: jiraToken }
29
+ : undefined;
30
+ const linearKey = clean(env.LINEAR_API_KEY);
31
+ const linear = linearKey
32
+ ? { apiKey: linearKey }
33
+ : undefined;
34
+ const slackToken = clean(env.SLACK_TOKEN) ?? clean(env.SLACK_BOT_TOKEN);
35
+ const slackChannels = clean(env.SLACK_CHANNELS)
36
+ ?.split(",")
37
+ .map((c) => c.trim())
38
+ .filter(Boolean);
39
+ const slack = slackToken
40
+ ? { token: slackToken, channels: slackChannels }
41
+ : undefined;
42
+ const anyConfigured = Boolean(github || jira || linear || slack);
43
+ if (forceDemo || !anyConfigured) {
44
+ return { demoMode: true, displayName };
45
+ }
46
+ return { demoMode: false, displayName, github, jira, linear, slack };
47
+ }
48
+ /** Which sources are wired up in this config (empty in demo mode). */
49
+ export function configuredSources(config) {
50
+ const out = [];
51
+ if (config.github)
52
+ out.push("github");
53
+ if (config.jira)
54
+ out.push("jira");
55
+ if (config.linear)
56
+ out.push("linear");
57
+ if (config.slack)
58
+ out.push("slack");
59
+ return out;
60
+ }
61
+ /** One-line human summary of how the server is configured, for logs. */
62
+ export function describeConfig(config) {
63
+ if (config.demoMode) {
64
+ return "demo mode (synthetic data, no source credentials configured)";
65
+ }
66
+ return `live mode across ${configuredSources(config).join(", ")}`;
67
+ }
package/dist/demo.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `--demo` mode: render every tool against the synthetic dataset and print to
3
+ * stdout, then exit. This is what makes `npx standup-mcp --demo` show a real
4
+ * standup in a plain terminal instead of starting a stdio server that silently
5
+ * waits for an MCP client.
6
+ *
7
+ * `--check` mode: verify each configured live connection end to end.
8
+ */
9
+ import { MockProvider } from "./sources/mock.js";
10
+ import { createProvider } from "./provider.js";
11
+ import { describeConfig, loadConfig } from "./config.js";
12
+ import { resolveWindow, lastNDaysWindow } from "./window.js";
13
+ import { buildStandupDraft } from "./analytics/draft.js";
14
+ import { detectBlockers, formatBlockerReport } from "./analytics/blockers.js";
15
+ import { buildDigest } from "./analytics/digest.js";
16
+ import { buildWeeklySummary } from "./analytics/weekly.js";
17
+ function divider(title) {
18
+ const bar = "=".repeat(66);
19
+ return `\n${bar}\n ${title}\n${bar}`;
20
+ }
21
+ export async function runDemo() {
22
+ const nowMs = Date.now();
23
+ const now = new Date(nowMs);
24
+ const provider = new MockProvider(nowMs);
25
+ const actor = await provider.resolveActor();
26
+ const window = resolveWindow({}, now);
27
+ const events = await provider.fetchActivity();
28
+ const openItems = await provider.fetchOpenItems();
29
+ const nowIso = now.toISOString();
30
+ const out = [];
31
+ out.push("standup-mcp demo. Synthetic GitHub + Jira + Linear + Slack activity, no credentials needed.");
32
+ out.push("This is the output your AI assistant gets back from each tool.");
33
+ out.push(divider("standup_draft"));
34
+ out.push(buildStandupDraft({ actor, window, events, openItems, nowIso, isDemo: true }));
35
+ out.push(divider("blocker_scan"));
36
+ out.push(formatBlockerReport(detectBlockers(events, openItems, nowIso), window, true));
37
+ out.push(divider("activity_digest"));
38
+ out.push(buildDigest(events, window, true));
39
+ out.push(divider("weekly_summary"));
40
+ out.push(buildWeeklySummary(events, lastNDaysWindow(7, now), actor, true));
41
+ out.push("\n" + "-".repeat(66));
42
+ out.push("Next steps:");
43
+ out.push(" Connect your tools: set GITHUB_TOKEN, JIRA_BASE_URL/JIRA_EMAIL/JIRA_API_TOKEN, LINEAR_API_KEY, SLACK_TOKEN.");
44
+ out.push(" Use it in Claude Desktop, Cursor, Cline, and other MCP clients.");
45
+ out.push(" Docs: https://github.com/sathvic-kollu/standup-mcp");
46
+ console.log(out.join("\n"));
47
+ }
48
+ export async function runCheck() {
49
+ const config = loadConfig();
50
+ if (config.demoMode) {
51
+ console.log("No source credentials configured, so there is nothing to check.");
52
+ console.log("Set GITHUB_TOKEN, JIRA_BASE_URL/JIRA_EMAIL/JIRA_API_TOKEN, LINEAR_API_KEY, or SLACK_TOKEN, then run --check again.");
53
+ console.log("To see sample output on demo data instead, run: standup-mcp --demo");
54
+ return;
55
+ }
56
+ console.log(`Checking ${describeConfig(config)} ...`);
57
+ const provider = createProvider(config);
58
+ const statuses = await provider.checkConnections();
59
+ let anyFail = false;
60
+ for (const s of statuses) {
61
+ const mark = s.ok ? "ok " : "FAIL";
62
+ console.log(` [${mark}] ${s.source}: ${s.detail}`);
63
+ if (!s.ok)
64
+ anyFail = true;
65
+ }
66
+ if (anyFail) {
67
+ console.error("\nOne or more sources failed. Fix the token or URL and run --check again.");
68
+ process.exitCode = 1;
69
+ }
70
+ else {
71
+ console.log("\nAll configured sources connected. standup-mcp is ready to use.");
72
+ }
73
+ }
74
+ export function printHelp() {
75
+ console.log([
76
+ "standup-mcp. Generate your daily standup from real activity across",
77
+ "GitHub, Jira, Linear, and Slack.",
78
+ "",
79
+ "Usage:",
80
+ " standup-mcp Start the MCP server on stdio (for an MCP client).",
81
+ " standup-mcp --demo Print a sample standup from synthetic data, then exit.",
82
+ " standup-mcp --check Verify your configured live connections.",
83
+ " standup-mcp --help Show this help.",
84
+ "",
85
+ "Configure any subset of sources (read-only tokens):",
86
+ " GITHUB_TOKEN",
87
+ " JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN",
88
+ " LINEAR_API_KEY",
89
+ " SLACK_TOKEN",
90
+ "With none set, the server runs on built-in demo data.",
91
+ "Docs: https://github.com/sathvic-kollu/standup-mcp",
92
+ ].join("\n"));
93
+ }
package/dist/format.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Small markdown helpers so every tool returns output you can paste straight
3
+ * into Slack, Geekbot, a standup doc, or a ticket comment.
4
+ */
5
+ const MONTHS = [
6
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
7
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
8
+ ];
9
+ const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
10
+ /** "Jun 16". UTC, to match window math. */
11
+ export function fmtDate(iso) {
12
+ if (!iso)
13
+ return "n/a";
14
+ const d = new Date(iso);
15
+ if (Number.isNaN(d.getTime()))
16
+ return "n/a";
17
+ return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;
18
+ }
19
+ /** "Mon Jun 16". */
20
+ export function fmtDayDate(iso) {
21
+ if (!iso)
22
+ return "n/a";
23
+ const d = new Date(iso);
24
+ if (Number.isNaN(d.getTime()))
25
+ return "n/a";
26
+ return `${DAYS[d.getUTCDay()]} ${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;
27
+ }
28
+ /** "9:30am". UTC. */
29
+ export function fmtTime(iso) {
30
+ if (!iso)
31
+ return "";
32
+ const d = new Date(iso);
33
+ if (Number.isNaN(d.getTime()))
34
+ return "";
35
+ let h = d.getUTCHours();
36
+ const m = d.getUTCMinutes().toString().padStart(2, "0");
37
+ const ap = h < 12 ? "am" : "pm";
38
+ h = h % 12 || 12;
39
+ return `${h}:${m}${ap}`;
40
+ }
41
+ export function plural(n, one, many = `${one}s`) {
42
+ return `${n} ${n === 1 ? one : many}`;
43
+ }
44
+ /** Whole days between two ISO timestamps (b - a), never negative. */
45
+ export function daysBetween(aIso, bIso) {
46
+ const a = Date.parse(aIso);
47
+ const b = Date.parse(bIso);
48
+ if (Number.isNaN(a) || Number.isNaN(b))
49
+ return 0;
50
+ return Math.max(0, Math.floor((b - a) / 86_400_000));
51
+ }
52
+ export function bullets(items) {
53
+ return items.length ? items.map((i) => `- ${i}`).join("\n") : "_none_";
54
+ }
55
+ export function heading(level, text) {
56
+ return `${"#".repeat(level)} ${text}`;
57
+ }
58
+ const SOURCE_LABEL = {
59
+ github: "GitHub",
60
+ jira: "Jira",
61
+ linear: "Linear",
62
+ slack: "Slack",
63
+ };
64
+ export function sourceLabel(s) {
65
+ return SOURCE_LABEL[s];
66
+ }
package/dist/index.js ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * standup-mcp entry point.
4
+ *
5
+ * A Model Context Protocol server that generates your daily standup from real
6
+ * activity across GitHub, Jira, Linear, and Slack, over stdio. Runs in demo
7
+ * mode automatically when no source credentials are configured.
8
+ *
9
+ * Local and read-only: the server makes no LLM calls and needs no AI API key.
10
+ * The host AI client supplies the model; the only credentials are read-only
11
+ * source tokens, and nothing leaves the machine except calls to those APIs.
12
+ */
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
+ import { describeConfig, loadConfig } from "./config.js";
16
+ import { createProvider } from "./provider.js";
17
+ import { registerAllTools } from "./tools/registry.js";
18
+ import { printHelp, runCheck, runDemo } from "./demo.js";
19
+ export const SERVER_NAME = "standup-mcp";
20
+ export const SERVER_VERSION = "0.1.0";
21
+ export function buildServer(deps) {
22
+ const server = new McpServer({
23
+ name: SERVER_NAME,
24
+ version: SERVER_VERSION,
25
+ });
26
+ registerAllTools(server, deps);
27
+ return server;
28
+ }
29
+ async function main() {
30
+ const args = process.argv.slice(2);
31
+ if (args.includes("--help") || args.includes("-h")) {
32
+ printHelp();
33
+ return;
34
+ }
35
+ if (args.includes("--demo") || args.includes("demo")) {
36
+ await runDemo();
37
+ return;
38
+ }
39
+ if (args.includes("--check") || args.includes("check")) {
40
+ await runCheck();
41
+ return;
42
+ }
43
+ const config = loadConfig();
44
+ const provider = createProvider(config);
45
+ const server = buildServer({ provider, config });
46
+ const transport = new StdioServerTransport();
47
+ await server.connect(transport);
48
+ // Logs must go to stderr; stdout is the MCP protocol channel.
49
+ console.error(`${SERVER_NAME} v${SERVER_VERSION} running in ${describeConfig(config)}`);
50
+ // If a person ran this directly in a terminal (stdin is a TTY, so no MCP
51
+ // client is driving it), tell them what to do instead of appearing to hang.
52
+ if (process.stdin.isTTY) {
53
+ console.error("\nThis is an MCP server. It is now waiting for a client on stdin.\n" +
54
+ "To see a sample standup instead, run: npx standup-mcp --demo\n" +
55
+ "To use it, add it to an MCP client (Claude Desktop, Cursor, Cline):\n" +
56
+ " https://github.com/sathvic-kollu/standup-mcp\n" +
57
+ "Press Ctrl+C to exit.");
58
+ }
59
+ }
60
+ main().catch((err) => {
61
+ console.error("Fatal error starting standup-mcp:", err);
62
+ process.exit(1);
63
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Shared, pure normalization helpers used by every source client and by the
3
+ * demo provider. The risky, heuristic parsing lives here in one place so it can
4
+ * be unit tested in isolation (test/normalize.test.ts).
5
+ */
6
+ // --- Work-item key extraction --------------------------------------------
7
+ //
8
+ // The whole point of grouping "by work item, not by tool" is tying a GitHub
9
+ // branch/PR/commit back to its Jira or Linear ticket. Keys look like PROJ-412
10
+ // or ENG-45: an uppercase project prefix, a dash, and a number.
11
+ const KEY_RE = /\b([A-Z][A-Z0-9]{1,9})-(\d{1,6})\b/g;
12
+ // Common non-ticket tokens that match the key shape (UTF-8, SHA-256, ...).
13
+ // Excluding these removes the bulk of false positives in commit messages.
14
+ const KEY_DENYLIST = new Set([
15
+ "UTF",
16
+ "SHA",
17
+ "MD",
18
+ "AES",
19
+ "RSA",
20
+ "ISO",
21
+ "RFC",
22
+ "IPV",
23
+ "IP",
24
+ "HTTP",
25
+ "HTTPS",
26
+ "TLS",
27
+ "SSL",
28
+ "EC",
29
+ "ES",
30
+ "CVE",
31
+ "PR",
32
+ "CI",
33
+ "CD",
34
+ "API",
35
+ "URL",
36
+ "UUID",
37
+ "BASE",
38
+ "X",
39
+ ]);
40
+ /**
41
+ * Return the first plausible work-item key in a string (branch name, PR title,
42
+ * commit message), or undefined. Keys are uppercased and the prefix is checked
43
+ * against a denylist of look-alike tokens.
44
+ */
45
+ export function extractWorkItemKey(text) {
46
+ if (!text)
47
+ return undefined;
48
+ KEY_RE.lastIndex = 0;
49
+ let m;
50
+ while ((m = KEY_RE.exec(text)) !== null) {
51
+ const prefix = m[1].toUpperCase();
52
+ if (KEY_DENYLIST.has(prefix))
53
+ continue;
54
+ return `${prefix}-${m[2]}`;
55
+ }
56
+ return undefined;
57
+ }
58
+ // --- Language signals -----------------------------------------------------
59
+ const BLOCKED_RE = /\b(blocked|blocker|waiting on|waiting for|stuck|can'?t (?:repro|reproduce|get|access)|depends? on|dependency on|need(?:s|ed)? (?:access|creds|credentials|approval)|on hold)\b/i;
60
+ const HELP_RE = /\b(can someone|anyone (?:know|have)|need help|any ideas|help needed|pls help|please help|how do i|not sure how)\b/i;
61
+ /** Signals implied by free text (commit body, comment, Slack message). */
62
+ export function detectTextSignals(text) {
63
+ if (!text)
64
+ return [];
65
+ const out = [];
66
+ if (BLOCKED_RE.test(text))
67
+ out.push("blocked");
68
+ if (HELP_RE.test(text))
69
+ out.push("help_requested");
70
+ return out;
71
+ }
72
+ // --- Noise suppression ----------------------------------------------------
73
+ const MERGE_COMMIT_RE = /^(merge (branch|pull request|remote-tracking)|merge ")/i;
74
+ const TRIVIAL_COMMIT_RE = /^(bump version|update (package-lock|yarn\.lock|pnpm-lock|changelog)|wip\b|fixup!|squash!|amend\b)/i;
75
+ /** True for merge commits and trivial churn that should not appear in a standup. */
76
+ export function isNoiseCommit(message) {
77
+ if (!message)
78
+ return false;
79
+ const first = firstLine(message).trim();
80
+ return MERGE_COMMIT_RE.test(first) || TRIVIAL_COMMIT_RE.test(first);
81
+ }
82
+ const BOT_RE = /\[bot\]$|^(dependabot|renovate|github-actions|snyk-bot)\b/i;
83
+ /** True for automation accounts whose activity is not the user's work. */
84
+ export function isBotActor(login) {
85
+ if (!login)
86
+ return false;
87
+ return BOT_RE.test(login);
88
+ }
89
+ // --- Small text utilities -------------------------------------------------
90
+ export function firstLine(text) {
91
+ const idx = text.indexOf("\n");
92
+ return idx === -1 ? text : text.slice(0, idx);
93
+ }
94
+ export function truncate(text, max = 140) {
95
+ const clean = text.replace(/\s+/g, " ").trim();
96
+ return clean.length <= max ? clean : clean.slice(0, max - 1).trimEnd() + "…";
97
+ }
98
+ /** Title-case a bare verb phrase for a clean leading word, e.g. "merged" -> "Merged". */
99
+ export function cap(text) {
100
+ return text.length ? text[0].toUpperCase() + text.slice(1) : text;
101
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Builds the activity provider from config and fans every request out to the
3
+ * configured sources. Tools never construct sources themselves; they take an
4
+ * ActivityProvider, which keeps them identical across live and demo modes.
5
+ *
6
+ * Resilience matters here: one source being misconfigured or rate-limited must
7
+ * not sink the whole standup. Each source's failures are swallowed per call so
8
+ * the user still gets a draft from whatever did respond. Use --check to see
9
+ * which connections are actually healthy.
10
+ */
11
+ import { MockProvider } from "./sources/mock.js";
12
+ import { GithubSource } from "./sources/github.js";
13
+ import { JiraSource } from "./sources/jira.js";
14
+ import { LinearSource } from "./sources/linear.js";
15
+ import { SlackSource } from "./sources/slack.js";
16
+ export function createProvider(config) {
17
+ if (config.demoMode)
18
+ return new MockProvider();
19
+ const clients = [];
20
+ if (config.github)
21
+ clients.push(new GithubSource(config.github));
22
+ if (config.jira)
23
+ clients.push(new JiraSource(config.jira));
24
+ if (config.linear)
25
+ clients.push(new LinearSource(config.linear));
26
+ if (config.slack)
27
+ clients.push(new SlackSource(config.slack));
28
+ return new LiveProvider(clients, config);
29
+ }
30
+ class LiveProvider {
31
+ clients;
32
+ config;
33
+ isDemo = false;
34
+ sources;
35
+ constructor(clients, config) {
36
+ this.clients = clients;
37
+ this.config = config;
38
+ this.sources = clients.map((c) => c.id);
39
+ }
40
+ async resolveActor() {
41
+ const actor = { displayName: this.config.displayName ?? "you" };
42
+ await Promise.all(this.clients.map(async (c) => {
43
+ try {
44
+ const id = await c.resolveIdentity();
45
+ if (id)
46
+ actor[c.id] = id;
47
+ }
48
+ catch {
49
+ // Leave this source's identity unset; it just won't match events.
50
+ }
51
+ }));
52
+ // Without an explicit name, fall back to the GitHub handle (the one
53
+ // human-readable identity we resolve) rather than a bare "you".
54
+ if (!this.config.displayName && actor.github) {
55
+ actor.displayName = actor.github;
56
+ }
57
+ return actor;
58
+ }
59
+ async fetchActivity(window, actor) {
60
+ const batches = await Promise.all(this.clients.map((c) => c.fetchActivity(window, actor).catch(() => [])));
61
+ return batches
62
+ .flat()
63
+ .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
64
+ }
65
+ async fetchOpenItems(actor) {
66
+ const batches = await Promise.all(this.clients.map((c) => c.fetchOpenItems(actor).catch(() => [])));
67
+ return batches.flat();
68
+ }
69
+ async checkConnections() {
70
+ return Promise.all(this.clients.map((c) => c.checkConnection().catch((e) => ({
71
+ source: c.id,
72
+ ok: false,
73
+ detail: e instanceof Error ? e.message : String(e),
74
+ }))));
75
+ }
76
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Live GitHub source. Implements ActivitySource against the GitHub REST API
3
+ * (https://api.github.com) with a personal-access-token bearer credential.
4
+ *
5
+ * Read-only: every call is a GET against /user or the search endpoints. We use
6
+ * search (issues for PRs, commits for commits) rather than per-repo listing so
7
+ * one query spans every repo the token can see, which is what a standup needs.
8
+ * Each search slice is wrapped so a single failing query degrades gracefully
9
+ * instead of sinking the whole fetch.
10
+ */
11
+ import { extractWorkItemKey, firstLine, isNoiseCommit } from "../normalize.js";
12
+ const API_BASE = "https://api.github.com";
13
+ const REQUEST_TIMEOUT_MS = 30_000;
14
+ export class GithubSource {
15
+ creds;
16
+ id = "github";
17
+ isDemo = false;
18
+ constructor(creds) {
19
+ this.creds = creds;
20
+ }
21
+ // --- HTTP plumbing -------------------------------------------------------
22
+ async request(path) {
23
+ const url = path.startsWith("http") ? path : `${API_BASE}${path}`;
24
+ const controller = new AbortController();
25
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
26
+ try {
27
+ const res = await fetch(url, {
28
+ method: "GET",
29
+ headers: {
30
+ Authorization: `Bearer ${this.creds.token}`,
31
+ Accept: "application/vnd.github+json",
32
+ "X-GitHub-Api-Version": "2022-11-28",
33
+ "User-Agent": "standup-mcp",
34
+ },
35
+ signal: controller.signal,
36
+ });
37
+ if (!res.ok) {
38
+ const body = await res.text().catch(() => "");
39
+ throw new Error(`GitHub API ${res.status} ${res.statusText} for ${path}` +
40
+ (body ? `: ${body.slice(0, 300)}` : ""));
41
+ }
42
+ return (await res.json());
43
+ }
44
+ catch (err) {
45
+ if (err instanceof Error && err.name === "AbortError") {
46
+ throw new Error(`GitHub API request timed out after 30s: ${path}`);
47
+ }
48
+ throw err;
49
+ }
50
+ finally {
51
+ clearTimeout(timer);
52
+ }
53
+ }
54
+ /** Build a /search/{kind} path with a safely-encoded query string. */
55
+ searchPath(kind, q, perPage) {
56
+ const params = new URLSearchParams({
57
+ q,
58
+ per_page: String(perPage),
59
+ });
60
+ return `/search/${kind}?${params.toString()}`;
61
+ }
62
+ /**
63
+ * Run one PR (issues) search and map each hit to an event. The five PR
64
+ * searches differ only in query, kind, title verb, timestamp, and signals,
65
+ * so the request + map + per-slice skip-on-failure lives here once.
66
+ */
67
+ async searchIssueEvents(q, perPage, toEvent) {
68
+ try {
69
+ const res = await this.request(this.searchPath("issues", q, perPage));
70
+ return res.items.map(toEvent);
71
+ }
72
+ catch {
73
+ return []; // One failing query must not sink the whole fetch.
74
+ }
75
+ }
76
+ // --- Identity ------------------------------------------------------------
77
+ async resolveIdentity() {
78
+ const user = await this.request("/user");
79
+ return user.login;
80
+ }
81
+ async checkConnection() {
82
+ try {
83
+ const login = await this.resolveIdentity();
84
+ return {
85
+ source: "github",
86
+ ok: true,
87
+ identity: login,
88
+ detail: `authenticated as ${login}`,
89
+ };
90
+ }
91
+ catch (err) {
92
+ return {
93
+ source: "github",
94
+ ok: false,
95
+ detail: err instanceof Error ? err.message : String(err),
96
+ };
97
+ }
98
+ }
99
+ // --- Activity ------------------------------------------------------------
100
+ async fetchActivity(window, actor) {
101
+ const login = actor.github ?? (await this.resolveIdentity());
102
+ const sinceDate = window.since.slice(0, 10); // YYYY-MM-DD
103
+ const sinceMs = Date.parse(window.since);
104
+ const events = [];
105
+ // Opened PRs.
106
+ events.push(...(await this.searchIssueEvents(`type:pr author:${login} created:>=${sinceDate}`, 50, (item) => ({
107
+ source: "github",
108
+ kind: "pr_opened",
109
+ timestamp: item.created_at,
110
+ actor: login,
111
+ title: `Opened PR #${item.number}: ${item.title}`,
112
+ workItem: extractWorkItemKey(item.title),
113
+ url: item.html_url,
114
+ signals: [],
115
+ }))));
116
+ // Merged PRs.
117
+ events.push(...(await this.searchIssueEvents(`type:pr author:${login} is:merged merged:>=${sinceDate}`, 50, (item) => ({
118
+ source: "github",
119
+ kind: "pr_merged",
120
+ timestamp: item.closed_at ?? item.updated_at,
121
+ actor: login,
122
+ title: `Merged PR #${item.number}: ${item.title}`,
123
+ workItem: extractWorkItemKey(item.title),
124
+ url: item.html_url,
125
+ signals: [],
126
+ }))));
127
+ // Reviewed PRs (someone else's, where you left a review).
128
+ events.push(...(await this.searchIssueEvents(`type:pr reviewed-by:${login} -author:${login} updated:>=${sinceDate}`, 50, (item) => ({
129
+ source: "github",
130
+ kind: "pr_reviewed",
131
+ timestamp: item.updated_at,
132
+ actor: login,
133
+ title: `Reviewed PR #${item.number}: ${item.title}`,
134
+ workItem: extractWorkItemKey(item.title),
135
+ url: item.html_url,
136
+ signals: [],
137
+ }))));
138
+ // Commits. Different response shape: no number, message under .commit.
139
+ try {
140
+ const res = await this.request(this.searchPath("commits", `author:${login} author-date:>=${sinceDate}`, 50));
141
+ for (const item of res.items) {
142
+ const message = item.commit.message;
143
+ if (isNoiseCommit(message))
144
+ continue;
145
+ events.push({
146
+ source: "github",
147
+ kind: "commit",
148
+ timestamp: item.commit.author.date,
149
+ actor: login,
150
+ title: firstLine(message),
151
+ workItem: extractWorkItemKey(message),
152
+ url: item.html_url,
153
+ signals: [],
154
+ });
155
+ }
156
+ }
157
+ catch {
158
+ // Skip this slice.
159
+ }
160
+ // The date-granular qualifiers admit events from earlier on sinceDate;
161
+ // keep only those at or after the precise window start. Since-only by spec.
162
+ return events.filter((e) => Date.parse(e.timestamp) >= sinceMs);
163
+ }
164
+ // --- Open items ----------------------------------------------------------
165
+ async fetchOpenItems(actor) {
166
+ const login = actor.github ?? (await this.resolveIdentity());
167
+ const events = [];
168
+ // Your open PRs. A non-draft open PR is awaiting review.
169
+ events.push(...(await this.searchIssueEvents(`type:pr author:${login} state:open`, 30, (item) => ({
170
+ source: "github",
171
+ kind: "pr_opened",
172
+ timestamp: item.updated_at,
173
+ actor: login,
174
+ title: `Opened PR #${item.number}: ${item.title}`,
175
+ workItem: extractWorkItemKey(item.title),
176
+ url: item.html_url,
177
+ signals: item.draft ? [] : ["review_pending"],
178
+ }))));
179
+ // Reviews requested of you.
180
+ events.push(...(await this.searchIssueEvents(`type:pr review-requested:${login} state:open`, 30, (item) => ({
181
+ source: "github",
182
+ kind: "pr_review_requested",
183
+ timestamp: item.updated_at,
184
+ actor: login,
185
+ title: `Review PR #${item.number}: ${item.title}`,
186
+ workItem: extractWorkItemKey(item.title),
187
+ url: item.html_url,
188
+ signals: [],
189
+ }))));
190
+ return events;
191
+ }
192
+ }