wayfind 0.0.1 → 2.0.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/BOOTSTRAP_PROMPT.md +120 -0
- package/bin/connectors/github.js +617 -0
- package/bin/connectors/index.js +13 -0
- package/bin/connectors/intercom.js +595 -0
- package/bin/connectors/llm.js +469 -0
- package/bin/connectors/notion.js +747 -0
- package/bin/connectors/transport.js +325 -0
- package/bin/content-store.js +2006 -0
- package/bin/digest.js +813 -0
- package/bin/rebuild-status.js +297 -0
- package/bin/slack-bot.js +1535 -0
- package/bin/slack.js +342 -0
- package/bin/storage/index.js +171 -0
- package/bin/storage/json-backend.js +348 -0
- package/bin/storage/sqlite-backend.js +415 -0
- package/bin/team-context.js +4209 -0
- package/bin/telemetry.js +159 -0
- package/doctor.sh +291 -0
- package/install.sh +144 -0
- package/journal-summary.sh +577 -0
- package/package.json +48 -6
- package/setup.sh +641 -0
- package/specializations/claude-code/CLAUDE.md-global-fragment.md +53 -0
- package/specializations/claude-code/CLAUDE.md-repo-fragment.md +16 -0
- package/specializations/claude-code/README.md +99 -0
- package/specializations/claude-code/commands/doctor.md +31 -0
- package/specializations/claude-code/commands/init-memory.md +154 -0
- package/specializations/claude-code/commands/init-team.md +415 -0
- package/specializations/claude-code/commands/journal.md +66 -0
- package/specializations/claude-code/commands/review-prs.md +119 -0
- package/specializations/claude-code/hooks/check-global-state.sh +20 -0
- package/specializations/claude-code/hooks/session-end.sh +36 -0
- package/specializations/claude-code/settings.json +15 -0
- package/specializations/cursor/README.md +120 -0
- package/specializations/cursor/global-rule.mdc +53 -0
- package/specializations/cursor/repo-rule.mdc +25 -0
- package/specializations/generic/README.md +47 -0
- package/templates/autopilot/design.md +22 -0
- package/templates/autopilot/engineering.md +22 -0
- package/templates/autopilot/product.md +22 -0
- package/templates/autopilot/strategy.md +22 -0
- package/templates/autopilot/unified.md +24 -0
- package/templates/deploy/.env.example +110 -0
- package/templates/deploy/docker-compose.yml +63 -0
- package/templates/deploy/slack-app-manifest.json +45 -0
- package/templates/github-actions/meridian-digest.yml +85 -0
- package/templates/global.md +79 -0
- package/templates/memory-file.md +18 -0
- package/templates/personal-state.md +14 -0
- package/templates/personas.json +28 -0
- package/templates/product-state.md +41 -0
- package/templates/prompts-readme.md +19 -0
- package/templates/repo-state.md +18 -0
- package/templates/session-protocol-fragment.md +46 -0
- package/templates/slack-app-manifest.json +27 -0
- package/templates/statusline.sh +22 -0
- package/templates/strategy-state.md +39 -0
- package/templates/team-state.md +55 -0
- package/uninstall.sh +105 -0
- package/README.md +0 -4
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const readline = require('readline');
|
|
6
|
+
const transport = require('./transport');
|
|
7
|
+
|
|
8
|
+
const HOME = process.env.HOME || process.env.USERPROFILE;
|
|
9
|
+
const WAYFIND_DIR = path.join(HOME, '.claude', 'team-context');
|
|
10
|
+
const CONNECTORS_FILE = path.join(WAYFIND_DIR, 'connectors.json');
|
|
11
|
+
const SIGNALS_DIR = path.join(WAYFIND_DIR, 'signals');
|
|
12
|
+
|
|
13
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function ask(question) {
|
|
16
|
+
const rl = readline.createInterface({
|
|
17
|
+
input: process.stdin,
|
|
18
|
+
output: process.stdout,
|
|
19
|
+
});
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
rl.question(question, (answer) => {
|
|
22
|
+
rl.close();
|
|
23
|
+
resolve(answer.trim());
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readConfig() {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(fs.readFileSync(CONNECTORS_FILE, 'utf8'));
|
|
31
|
+
} catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeConfig(config) {
|
|
37
|
+
fs.mkdirSync(path.dirname(CONNECTORS_FILE), { recursive: true });
|
|
38
|
+
fs.writeFileSync(CONNECTORS_FILE, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function localDateStr(d) {
|
|
42
|
+
const y = d.getFullYear();
|
|
43
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
44
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
45
|
+
return `${y}-${m}-${day}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function today() {
|
|
49
|
+
return localDateStr(new Date());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function yesterday() {
|
|
53
|
+
const d = new Date();
|
|
54
|
+
d.setDate(d.getDate() - 1);
|
|
55
|
+
return localDateStr(d);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function daysBetween(dateStr1, dateStr2) {
|
|
59
|
+
const d1 = new Date(dateStr1);
|
|
60
|
+
const d2 = new Date(dateStr2);
|
|
61
|
+
return Math.floor(Math.abs(d2 - d1) / (1000 * 60 * 60 * 24));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Normalize a repo entry to { owner, name } regardless of input format.
|
|
66
|
+
* Accepts "owner/repo" string or { owner, repo } object.
|
|
67
|
+
*/
|
|
68
|
+
function parseRepo(repo) {
|
|
69
|
+
if (!repo) throw new Error('repo is required');
|
|
70
|
+
if (typeof repo === 'string') {
|
|
71
|
+
const parts = repo.split('/');
|
|
72
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
73
|
+
throw new Error(`Invalid repo format: "${repo}". Expected owner/repo.`);
|
|
74
|
+
}
|
|
75
|
+
return { owner: parts[0], name: parts[1] };
|
|
76
|
+
}
|
|
77
|
+
const owner = repo.owner;
|
|
78
|
+
const name = repo.repo || repo.name;
|
|
79
|
+
if (!owner || !name) throw new Error('Invalid repo object: missing owner or repo.');
|
|
80
|
+
return { owner, name };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatDuration(seconds) {
|
|
84
|
+
if (seconds == null || isNaN(seconds)) return '-';
|
|
85
|
+
if (seconds < 60) return `${seconds}s`;
|
|
86
|
+
const m = Math.floor(seconds / 60);
|
|
87
|
+
const s = seconds % 60;
|
|
88
|
+
return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function runDurationSeconds(run) {
|
|
92
|
+
if (!run.created_at || !run.updated_at) return null;
|
|
93
|
+
if (run.status !== 'completed') return null;
|
|
94
|
+
const start = new Date(run.created_at).getTime();
|
|
95
|
+
const end = new Date(run.updated_at).getTime();
|
|
96
|
+
return Math.floor((end - start) / 1000);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Transport dispatch ──────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
async function apiGet(config, endpoint, params, repoOwner) {
|
|
102
|
+
if (config.transport === 'gh-cli') {
|
|
103
|
+
const ghUser = (config.accounts && repoOwner) ? config.accounts[repoOwner] : undefined;
|
|
104
|
+
return transport.ghCli.get(endpoint, params, ghUser ? { ghUser } : undefined);
|
|
105
|
+
}
|
|
106
|
+
return transport.https.get(config.token || '', endpoint, params);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Concurrency-limited Promise.all ─────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
function promiseAllLimited(tasks, limit) {
|
|
112
|
+
const results = new Array(tasks.length);
|
|
113
|
+
let index = 0;
|
|
114
|
+
|
|
115
|
+
function next() {
|
|
116
|
+
const i = index++;
|
|
117
|
+
if (i >= tasks.length) return Promise.resolve();
|
|
118
|
+
return tasks[i]().then((result) => {
|
|
119
|
+
results[i] = result;
|
|
120
|
+
return next();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const workers = [];
|
|
125
|
+
for (let w = 0; w < Math.min(limit, tasks.length); w++) {
|
|
126
|
+
workers.push(next());
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return Promise.all(workers).then(() => results);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Configure ───────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
async function configure() {
|
|
135
|
+
console.log('');
|
|
136
|
+
console.log('GitHub Connector Setup');
|
|
137
|
+
console.log('');
|
|
138
|
+
|
|
139
|
+
// Step 1: auto-detect transport
|
|
140
|
+
const detected = await transport.detect();
|
|
141
|
+
let transportType = detected.type;
|
|
142
|
+
let token = detected.token || '';
|
|
143
|
+
|
|
144
|
+
if (transportType === 'gh-cli') {
|
|
145
|
+
console.log('Detected: gh CLI authenticated');
|
|
146
|
+
} else if (token) {
|
|
147
|
+
console.log('Detected: HTTPS with token from environment');
|
|
148
|
+
} else {
|
|
149
|
+
console.log('No gh CLI found. A GitHub Personal Access Token is required.');
|
|
150
|
+
console.log('Required scopes: repo (or public_repo for public repos only)');
|
|
151
|
+
token = await ask('GitHub PAT: ');
|
|
152
|
+
if (!token) {
|
|
153
|
+
throw new Error('A token is required for HTTPS transport.');
|
|
154
|
+
}
|
|
155
|
+
transportType = 'https';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Step 2: select repos
|
|
159
|
+
console.log('');
|
|
160
|
+
const repoInput = await ask('Repos to track (comma-separated owner/repo): ');
|
|
161
|
+
const repos = repoInput
|
|
162
|
+
.split(',')
|
|
163
|
+
.map((r) => r.trim())
|
|
164
|
+
.filter(Boolean);
|
|
165
|
+
|
|
166
|
+
if (repos.length === 0) {
|
|
167
|
+
throw new Error('At least one repository is required.');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Validate format
|
|
171
|
+
for (const repo of repos) {
|
|
172
|
+
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repo)) {
|
|
173
|
+
throw new Error(`Invalid repo format: "${repo}". Expected owner/repo.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Step 3: build config object — caller (team-context.js) writes to disk
|
|
178
|
+
const repoObjects = repos.map((r) => {
|
|
179
|
+
const [owner, repo] = r.split('/');
|
|
180
|
+
return { owner, repo };
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Step 4: multi-account support for gh CLI
|
|
184
|
+
let accounts;
|
|
185
|
+
if (transportType === 'gh-cli') {
|
|
186
|
+
const ghAccounts = await transport.ghCli.listAccounts();
|
|
187
|
+
if (ghAccounts.length > 1) {
|
|
188
|
+
const uniqueOrgs = [...new Set(repoObjects.map((r) => r.owner))];
|
|
189
|
+
console.log('');
|
|
190
|
+
console.log(`Multiple gh accounts detected: ${ghAccounts.join(', ')}`);
|
|
191
|
+
console.log('Map each org/owner to the gh account that has access.');
|
|
192
|
+
console.log(`(press Enter to use the default active account)`);
|
|
193
|
+
accounts = {};
|
|
194
|
+
for (const org of uniqueOrgs) {
|
|
195
|
+
const answer = await ask(` ${org} → gh account [${ghAccounts.join('/')}]: `);
|
|
196
|
+
if (answer && ghAccounts.includes(answer)) {
|
|
197
|
+
accounts[org] = answer;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (Object.keys(accounts).length === 0) {
|
|
201
|
+
accounts = undefined;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const channelConfig = {
|
|
207
|
+
transport: transportType,
|
|
208
|
+
token: transportType === 'https' ? token : undefined,
|
|
209
|
+
accounts: accounts || undefined,
|
|
210
|
+
repos: repoObjects,
|
|
211
|
+
last_pull: null,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
console.log('');
|
|
215
|
+
console.log(`Configured ${repos.length} repo(s) with ${transportType} transport.`);
|
|
216
|
+
if (accounts && Object.keys(accounts).length > 0) {
|
|
217
|
+
for (const [org, user] of Object.entries(accounts)) {
|
|
218
|
+
console.log(` ${org} → ${user}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
console.log('');
|
|
222
|
+
|
|
223
|
+
return channelConfig;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Pull ────────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
async function pull(config, since) {
|
|
229
|
+
const sinceDate = since || yesterday();
|
|
230
|
+
const todayDate = today();
|
|
231
|
+
const timestamp = new Date().toISOString();
|
|
232
|
+
const repos = config.repos || [];
|
|
233
|
+
|
|
234
|
+
if (repos.length === 0) {
|
|
235
|
+
return { files: [], summary: 'No repos configured.', counts: { repos: 0, issues: 0, prs: 0, runs: 0 } };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Fetch data for all repos with concurrency limit
|
|
239
|
+
const tasks = repos.map((repo) => () => fetchRepoData(config, repo, sinceDate));
|
|
240
|
+
const repoResults = await promiseAllLimited(tasks, 5);
|
|
241
|
+
|
|
242
|
+
// Generate signal files
|
|
243
|
+
const files = [];
|
|
244
|
+
const repoHighlights = [];
|
|
245
|
+
let totalIssues = 0;
|
|
246
|
+
let totalPRs = 0;
|
|
247
|
+
let totalRuns = 0;
|
|
248
|
+
const allBlockedPRs = [];
|
|
249
|
+
const allFailedRuns = [];
|
|
250
|
+
const allIssueSpikes = [];
|
|
251
|
+
|
|
252
|
+
for (let i = 0; i < repos.length; i++) {
|
|
253
|
+
const repo = repos[i];
|
|
254
|
+
const data = repoResults[i];
|
|
255
|
+
const { owner, name: repoName } = parseRepo(repo);
|
|
256
|
+
const repoStr = `${owner}/${repoName}`;
|
|
257
|
+
|
|
258
|
+
// Generate per-repo markdown
|
|
259
|
+
const md = generateRepoMarkdown(owner, repoName, data, sinceDate, todayDate, timestamp);
|
|
260
|
+
|
|
261
|
+
// Write per-repo signal file
|
|
262
|
+
const repoDir = path.join(SIGNALS_DIR, 'github', owner, repoName);
|
|
263
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
264
|
+
const repoFile = path.join(repoDir, `${todayDate}.md`);
|
|
265
|
+
fs.writeFileSync(repoFile, md, 'utf8');
|
|
266
|
+
files.push(repoFile);
|
|
267
|
+
|
|
268
|
+
// Collect stats for summary
|
|
269
|
+
totalIssues += data.issues.length;
|
|
270
|
+
totalPRs += data.prs.length;
|
|
271
|
+
totalRuns += data.runs.length;
|
|
272
|
+
|
|
273
|
+
// Track blocked PRs (open > 5 days, no reviews)
|
|
274
|
+
const blocked = data.prs.filter((pr) => {
|
|
275
|
+
return pr.state === 'open' && daysBetween(pr.created_at, todayDate) > 5;
|
|
276
|
+
});
|
|
277
|
+
for (const pr of blocked) {
|
|
278
|
+
allBlockedPRs.push({ repo: repoStr, pr });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Track failed CI runs
|
|
282
|
+
const failed = data.runs.filter((r) => r.conclusion === 'failure');
|
|
283
|
+
for (const run of failed) {
|
|
284
|
+
allFailedRuns.push({ repo: repoStr, run });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Track issue spikes (more than 5 new issues)
|
|
288
|
+
const newIssues = data.issues.filter((iss) => iss.created_at && iss.created_at.slice(0, 10) >= sinceDate);
|
|
289
|
+
if (newIssues.length > 5) {
|
|
290
|
+
allIssueSpikes.push({ repo: repoStr, count: newIssues.length });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const openPRs = data.prs.filter((pr) => pr.state === 'open').length;
|
|
294
|
+
const mergedPRs = data.prs.filter((pr) => pr.merged_at).length;
|
|
295
|
+
const failedCount = failed.length;
|
|
296
|
+
|
|
297
|
+
const highlights = [];
|
|
298
|
+
highlights.push(`${data.prs.length} PRs, ${data.issues.length} issues, ${data.runs.length} CI runs`);
|
|
299
|
+
if (blocked.length > 0) {
|
|
300
|
+
highlights.push(`${blocked.length} PR(s) potentially blocked (open >5 days)`);
|
|
301
|
+
}
|
|
302
|
+
if (failedCount > 0) {
|
|
303
|
+
highlights.push(`${failedCount} CI failure(s)`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
repoHighlights.push({ repo: repoStr, openPRs, mergedPRs, highlights });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Generate rollup summary
|
|
310
|
+
const summary = generateSummaryMarkdown(
|
|
311
|
+
repoHighlights, allBlockedPRs, allFailedRuns, allIssueSpikes,
|
|
312
|
+
sinceDate, todayDate, repos.length, totalPRs, totalIssues, totalRuns
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const summaryDir = path.join(SIGNALS_DIR, 'github');
|
|
316
|
+
fs.mkdirSync(summaryDir, { recursive: true });
|
|
317
|
+
const summaryFile = path.join(summaryDir, `${todayDate}-summary.md`);
|
|
318
|
+
fs.writeFileSync(summaryFile, summary, 'utf8');
|
|
319
|
+
files.push(summaryFile);
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
files,
|
|
323
|
+
summary,
|
|
324
|
+
counts: { repos: repos.length, issues: totalIssues, prs: totalPRs, runs: totalRuns },
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Data fetching ───────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
async function fetchRepoData(config, repo, sinceDate) {
|
|
331
|
+
const { owner, name: repoName } = parseRepo(repo);
|
|
332
|
+
const base = `/repos/${owner}/${repoName}`;
|
|
333
|
+
|
|
334
|
+
const [issuesRaw, prsRaw, runsRaw] = await Promise.all([
|
|
335
|
+
apiGet(config, `${base}/issues`, { state: 'all', since: sinceDate, per_page: '100' }, owner),
|
|
336
|
+
apiGet(config, `${base}/pulls`, { state: 'all', sort: 'updated', per_page: '100' }, owner),
|
|
337
|
+
apiGet(config, `${base}/actions/runs`, { created: `>=${sinceDate}`, per_page: '100' }, owner),
|
|
338
|
+
]);
|
|
339
|
+
|
|
340
|
+
// Filter issues: exclude pull requests (GitHub returns PRs in the issues endpoint)
|
|
341
|
+
const issues = (issuesRaw || []).filter((item) => !item.pull_request);
|
|
342
|
+
|
|
343
|
+
// Filter PRs by updated_at >= since
|
|
344
|
+
const prs = (prsRaw || []).filter((pr) => {
|
|
345
|
+
if (!pr.updated_at) return true;
|
|
346
|
+
return pr.updated_at.slice(0, 10) >= sinceDate;
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Unwrap workflow_runs if needed (already handled in transport for simulation,
|
|
350
|
+
// but the live API wraps in { workflow_runs: [...] })
|
|
351
|
+
let runs = runsRaw || [];
|
|
352
|
+
if (!Array.isArray(runs) && runs.workflow_runs) {
|
|
353
|
+
runs = runs.workflow_runs;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { issues, prs, runs };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Markdown generation ─────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
function generateRepoMarkdown(owner, repoName, data, sinceDate, todayDate, timestamp) {
|
|
362
|
+
const lines = [];
|
|
363
|
+
|
|
364
|
+
lines.push(`# ${owner}/${repoName} — GitHub Signals`);
|
|
365
|
+
lines.push('');
|
|
366
|
+
lines.push(`**Period:** ${sinceDate} to ${todayDate} `);
|
|
367
|
+
lines.push(`**Pulled:** ${timestamp}`);
|
|
368
|
+
lines.push('');
|
|
369
|
+
|
|
370
|
+
// Pull Requests
|
|
371
|
+
lines.push('## Pull Requests');
|
|
372
|
+
lines.push('');
|
|
373
|
+
if (data.prs.length === 0) {
|
|
374
|
+
lines.push('No pull request activity this period.');
|
|
375
|
+
} else {
|
|
376
|
+
lines.push('| # | Title | Author | State | Updated | Reviews |');
|
|
377
|
+
lines.push('|---|-------|--------|-------|---------|---------|');
|
|
378
|
+
for (const pr of data.prs) {
|
|
379
|
+
const num = pr.number || '-';
|
|
380
|
+
const title = (pr.title || '').replace(/\|/g, '\\|');
|
|
381
|
+
const author = (pr.user && pr.user.login) || '-';
|
|
382
|
+
const state = pr.merged_at ? 'merged' : (pr.state || '-');
|
|
383
|
+
const updated = pr.updated_at ? pr.updated_at.slice(0, 10) : '-';
|
|
384
|
+
const reviews = (pr.requested_reviewers && pr.requested_reviewers.length) || 0;
|
|
385
|
+
lines.push(`| ${num} | ${title} | ${author} | ${state} | ${updated} | ${reviews} |`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Flag blocked PRs
|
|
389
|
+
const blockedPRs = data.prs.filter((pr) => {
|
|
390
|
+
return pr.state === 'open' && daysBetween(pr.created_at || todayDate, todayDate) > 5;
|
|
391
|
+
});
|
|
392
|
+
if (blockedPRs.length > 0) {
|
|
393
|
+
lines.push('');
|
|
394
|
+
for (const pr of blockedPRs) {
|
|
395
|
+
const age = daysBetween(pr.created_at || todayDate, todayDate);
|
|
396
|
+
lines.push(`> **Blocked:** PR #${pr.number} "${pr.title}" — open ${age} days, no reviews`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
lines.push('');
|
|
401
|
+
|
|
402
|
+
// Issues
|
|
403
|
+
lines.push('## Issues');
|
|
404
|
+
lines.push('');
|
|
405
|
+
if (data.issues.length === 0) {
|
|
406
|
+
lines.push('No issue activity this period.');
|
|
407
|
+
} else {
|
|
408
|
+
lines.push('| # | Title | Labels | State | Created | Age |');
|
|
409
|
+
lines.push('|---|-------|--------|-------|---------|-----|');
|
|
410
|
+
for (const iss of data.issues) {
|
|
411
|
+
const num = iss.number || '-';
|
|
412
|
+
const title = (iss.title || '').replace(/\|/g, '\\|');
|
|
413
|
+
const labels = (iss.labels || []).map((l) => (typeof l === 'string' ? l : l.name)).join(', ') || '-';
|
|
414
|
+
const state = iss.state || '-';
|
|
415
|
+
const created = iss.created_at ? iss.created_at.slice(0, 10) : '-';
|
|
416
|
+
const age = iss.created_at ? `${daysBetween(iss.created_at, todayDate)}d` : '-';
|
|
417
|
+
lines.push(`| ${num} | ${title} | ${labels} | ${state} | ${created} | ${age} |`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
lines.push('');
|
|
421
|
+
|
|
422
|
+
// CI/CD
|
|
423
|
+
lines.push('## CI/CD');
|
|
424
|
+
lines.push('');
|
|
425
|
+
if (data.runs.length === 0) {
|
|
426
|
+
lines.push('No CI/CD activity this period.');
|
|
427
|
+
} else {
|
|
428
|
+
lines.push('| Run | Workflow | Branch | Status | Conclusion | Duration |');
|
|
429
|
+
lines.push('|-----|----------|--------|--------|------------|----------|');
|
|
430
|
+
for (const run of data.runs) {
|
|
431
|
+
const id = run.id || '-';
|
|
432
|
+
const workflow = run.name || '-';
|
|
433
|
+
const branch = run.head_branch || '-';
|
|
434
|
+
const status = run.status || '-';
|
|
435
|
+
const conclusion = run.conclusion || '-';
|
|
436
|
+
const duration = formatDuration(runDurationSeconds(run));
|
|
437
|
+
lines.push(`| ${id} | ${workflow} | ${branch} | ${status} | ${conclusion} | ${duration} |`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const failures = data.runs.filter((r) => r.conclusion === 'failure');
|
|
441
|
+
const mainFailures = failures.filter((r) => r.head_branch === 'main' || r.head_branch === 'master');
|
|
442
|
+
if (mainFailures.length > 0) {
|
|
443
|
+
lines.push('');
|
|
444
|
+
lines.push(`> **Failures:** ${mainFailures.length} run(s) failed on main this period`);
|
|
445
|
+
} else if (failures.length > 0) {
|
|
446
|
+
lines.push('');
|
|
447
|
+
lines.push(`> **Failures:** ${failures.length} run(s) failed this period`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
lines.push('');
|
|
451
|
+
|
|
452
|
+
// Summary section
|
|
453
|
+
const openPRs = data.prs.filter((pr) => pr.state === 'open').length;
|
|
454
|
+
const mergedPRs = data.prs.filter((pr) => pr.merged_at).length;
|
|
455
|
+
const noReviewPRs = data.prs.filter((pr) => {
|
|
456
|
+
return pr.state === 'open' && (!pr.requested_reviewers || pr.requested_reviewers.length === 0);
|
|
457
|
+
}).length;
|
|
458
|
+
const newIssues = data.issues.filter((iss) => iss.created_at && iss.created_at.slice(0, 10) >= sinceDate).length;
|
|
459
|
+
const closedIssues = data.issues.filter((iss) => iss.state === 'closed').length;
|
|
460
|
+
const failedRuns = data.runs.filter((r) => r.conclusion === 'failure').length;
|
|
461
|
+
const failRate = data.runs.length > 0 ? Math.round((failedRuns / data.runs.length) * 100) : 0;
|
|
462
|
+
|
|
463
|
+
lines.push('## Summary');
|
|
464
|
+
lines.push(`- ${openPRs} PRs open, ${mergedPRs} merged, ${noReviewPRs} with no reviews (potentially blocked)`);
|
|
465
|
+
lines.push(`- ${newIssues} issues opened, ${closedIssues} closed`);
|
|
466
|
+
lines.push(`- ${data.runs.length} CI runs, ${failedRuns} failures (${failRate}% failure rate)`);
|
|
467
|
+
lines.push('');
|
|
468
|
+
|
|
469
|
+
return lines.join('\n');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function generateSummaryMarkdown(
|
|
473
|
+
repoHighlights, blockedPRs, failedRuns, issueSpikes,
|
|
474
|
+
sinceDate, todayDate, repoCount, totalPRs, totalIssues, totalRuns
|
|
475
|
+
) {
|
|
476
|
+
const lines = [];
|
|
477
|
+
|
|
478
|
+
lines.push('# GitHub Signals — Summary');
|
|
479
|
+
lines.push('');
|
|
480
|
+
lines.push(`**Period:** ${sinceDate} to ${todayDate} `);
|
|
481
|
+
lines.push(`**Repos:** ${repoCount}`);
|
|
482
|
+
lines.push('');
|
|
483
|
+
|
|
484
|
+
// Per-Repo Highlights
|
|
485
|
+
lines.push('## Per-Repo Highlights');
|
|
486
|
+
lines.push('');
|
|
487
|
+
for (const rh of repoHighlights) {
|
|
488
|
+
lines.push(`### ${rh.repo}`);
|
|
489
|
+
for (const h of rh.highlights) {
|
|
490
|
+
lines.push(`- ${h}`);
|
|
491
|
+
}
|
|
492
|
+
lines.push('');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Cross-Repo Patterns
|
|
496
|
+
lines.push('## Cross-Repo Patterns');
|
|
497
|
+
lines.push('');
|
|
498
|
+
|
|
499
|
+
if (blockedPRs.length > 0) {
|
|
500
|
+
lines.push('**PRs blocked across repos:**');
|
|
501
|
+
for (const { repo, pr } of blockedPRs) {
|
|
502
|
+
lines.push(`- ${repo}#${pr.number}: "${pr.title}"`);
|
|
503
|
+
}
|
|
504
|
+
} else {
|
|
505
|
+
lines.push('- No blocked PRs detected');
|
|
506
|
+
}
|
|
507
|
+
lines.push('');
|
|
508
|
+
|
|
509
|
+
if (failedRuns.length > 0) {
|
|
510
|
+
lines.push('**CI failure trends:**');
|
|
511
|
+
const byRepo = {};
|
|
512
|
+
for (const { repo, run } of failedRuns) {
|
|
513
|
+
byRepo[repo] = (byRepo[repo] || 0) + 1;
|
|
514
|
+
}
|
|
515
|
+
for (const [repo, count] of Object.entries(byRepo)) {
|
|
516
|
+
lines.push(`- ${repo}: ${count} failure(s)`);
|
|
517
|
+
}
|
|
518
|
+
} else {
|
|
519
|
+
lines.push('- No CI failures detected');
|
|
520
|
+
}
|
|
521
|
+
lines.push('');
|
|
522
|
+
|
|
523
|
+
if (issueSpikes.length > 0) {
|
|
524
|
+
lines.push('**Issue spikes:**');
|
|
525
|
+
for (const { repo, count } of issueSpikes) {
|
|
526
|
+
lines.push(`- ${repo}: ${count} new issues`);
|
|
527
|
+
}
|
|
528
|
+
} else {
|
|
529
|
+
lines.push('- No issue spikes detected');
|
|
530
|
+
}
|
|
531
|
+
lines.push('');
|
|
532
|
+
|
|
533
|
+
// Aggregated summary
|
|
534
|
+
const totalFailures = failedRuns.length;
|
|
535
|
+
const failRate = totalRuns > 0 ? Math.round((totalFailures / totalRuns) * 100) : 0;
|
|
536
|
+
|
|
537
|
+
lines.push('## Summary');
|
|
538
|
+
lines.push(`- ${totalPRs} PRs across ${repoCount} repos`);
|
|
539
|
+
lines.push(`- ${totalIssues} issues across ${repoCount} repos`);
|
|
540
|
+
lines.push(`- ${totalRuns} CI runs, ${totalFailures} failures (${failRate}% failure rate)`);
|
|
541
|
+
if (blockedPRs.length > 0) {
|
|
542
|
+
lines.push(`- ${blockedPRs.length} PR(s) potentially blocked across repos`);
|
|
543
|
+
}
|
|
544
|
+
lines.push('');
|
|
545
|
+
|
|
546
|
+
return lines.join('\n');
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ── Repo management ─────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
function addRepo(owner, repo) {
|
|
552
|
+
if (!owner || !repo) {
|
|
553
|
+
throw new Error('Both owner and repo are required.');
|
|
554
|
+
}
|
|
555
|
+
const full = `${owner}/${repo}`;
|
|
556
|
+
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(full)) {
|
|
557
|
+
throw new Error(`Invalid repo format: "${full}". Expected owner/repo.`);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const config = readConfig();
|
|
561
|
+
if (!config.github) {
|
|
562
|
+
throw new Error('GitHub connector not configured. Run configure() first.');
|
|
563
|
+
}
|
|
564
|
+
if (!Array.isArray(config.github.repos)) {
|
|
565
|
+
config.github.repos = [];
|
|
566
|
+
}
|
|
567
|
+
const exists = config.github.repos.some((r) => {
|
|
568
|
+
const { owner: o, name: n } = parseRepo(r);
|
|
569
|
+
return o === owner && n === repo;
|
|
570
|
+
});
|
|
571
|
+
if (exists) {
|
|
572
|
+
return false; // already present
|
|
573
|
+
}
|
|
574
|
+
config.github.repos.push({ owner, repo });
|
|
575
|
+
writeConfig(config);
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function removeRepo(owner, repo) {
|
|
580
|
+
if (!owner || !repo) {
|
|
581
|
+
throw new Error('Both owner and repo are required.');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const config = readConfig();
|
|
585
|
+
if (!config.github || !Array.isArray(config.github.repos)) {
|
|
586
|
+
throw new Error('GitHub connector not configured. Run configure() first.');
|
|
587
|
+
}
|
|
588
|
+
const before = config.github.repos.length;
|
|
589
|
+
config.github.repos = config.github.repos.filter((r) => {
|
|
590
|
+
const { owner: o, name: n } = parseRepo(r);
|
|
591
|
+
return !(o === owner && n === repo);
|
|
592
|
+
});
|
|
593
|
+
if (config.github.repos.length === before) {
|
|
594
|
+
return false; // not found
|
|
595
|
+
}
|
|
596
|
+
writeConfig(config);
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ── Summarize ───────────────────────────────────────────────────────────────
|
|
601
|
+
|
|
602
|
+
function summarize(filePath) {
|
|
603
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
604
|
+
const match = content.match(/## Summary\n([\s\S]*?)(?:\n## |\n$|$)/);
|
|
605
|
+
if (!match) {
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
return match[1].trim();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
module.exports = {
|
|
612
|
+
configure,
|
|
613
|
+
pull,
|
|
614
|
+
addRepo,
|
|
615
|
+
removeRepo,
|
|
616
|
+
summarize,
|
|
617
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const github = require('./github');
|
|
4
|
+
const intercom = require('./intercom');
|
|
5
|
+
const notion = require('./notion');
|
|
6
|
+
|
|
7
|
+
const connectors = { github, intercom, notion };
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
get(name) { return connectors[name] || null; },
|
|
11
|
+
list() { return Object.keys(connectors); },
|
|
12
|
+
all() { return connectors; },
|
|
13
|
+
};
|