whose-turn 0.0.0-stage → 1.0.0-alpha.3
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/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +244 -2
- package/config.example.json +144 -0
- package/connectors/demo.mjs +146 -0
- package/connectors/github.mjs +316 -0
- package/connectors/index.mjs +38 -0
- package/connectors/jira.mjs +155 -0
- package/connectors/linear.mjs +113 -0
- package/dist/assets/_plugin-vue_export-helper-BKveAJb2.css +1 -0
- package/dist/assets/_plugin-vue_export-helper-DHr5RHTx.js +18 -0
- package/dist/assets/bem-COKTjsky.js +1 -0
- package/dist/assets/index-CuyWocyb.js +3 -0
- package/dist/assets/index-p8D7awn0.css +1 -0
- package/dist/assets/report-BtGnm9ax.js +1 -0
- package/dist/assets/report-xVOo7qip.css +1 -0
- package/dist/assets/scheme-DJ3WNw60.js +1 -0
- package/dist/assets/sheet-CBYGM-FG.js +1 -0
- package/dist/assets/sheet-DYpaJaXO.css +1 -0
- package/dist/index.html +19 -0
- package/dist/report.html +16 -0
- package/dist/sheet.html +16 -0
- package/dist/vendor/popper.min.js +6 -0
- package/dist/vendor/tippy-bundle.umd.min.js +2 -0
- package/install.sh +69 -0
- package/lib/api.mjs +406 -0
- package/lib/backoff.mjs +2 -0
- package/lib/compose.mjs +67 -0
- package/lib/config.mjs +290 -0
- package/lib/default-table.mjs +58 -0
- package/lib/errors.mjs +55 -0
- package/lib/facts.mjs +128 -0
- package/lib/feedback.mjs +148 -0
- package/lib/handoff.mjs +127 -0
- package/lib/health.mjs +35 -0
- package/lib/http-client.mjs +44 -0
- package/lib/inbox.mjs +60 -0
- package/lib/linear-api.mjs +12 -0
- package/lib/notify.mjs +36 -0
- package/lib/palette.mjs +213 -0
- package/lib/platform/credential.ps1 +84 -0
- package/lib/registry.mjs +61 -0
- package/lib/rules.mjs +241 -0
- package/lib/secrets.mjs +69 -0
- package/lib/static.mjs +36 -0
- package/lib/store.mjs +61 -0
- package/lib/table.mjs +148 -0
- package/lib/text.mjs +46 -0
- package/lib/token-cache.mjs +12 -0
- package/lib/update.mjs +134 -0
- package/lib/window.mjs +33 -0
- package/lib/workspace.mjs +294 -0
- package/open.sh +40 -0
- package/package.json +67 -4
- package/server.mjs +96 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** Demo connector: three invented projects with every state the rules know, timestamps relative to now, and a little life on later polls. No network. */
|
|
2
|
+
import { toIsoUtc } from '../lib/text.mjs';
|
|
3
|
+
|
|
4
|
+
export const id = 'demo';
|
|
5
|
+
export const label = 'Demo';
|
|
6
|
+
export const links = () => [{ label: 'Demo tracker', url: 'https://demo.example/' }];
|
|
7
|
+
export const fields = [
|
|
8
|
+
{ key: 'scenario', label: 'Scenario', hint: 'northwind, atlas or garden', required: true },
|
|
9
|
+
{ key: 'part', label: 'Part', hint: 'tickets (the tracker side), code (PRs and asks), or empty for both' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const HOUR = 3600000;
|
|
13
|
+
let base = Date.now();
|
|
14
|
+
const ago = (hours) => toIsoUtc(base - hours * HOUR);
|
|
15
|
+
const SITE = 'https://demo.example/browse/';
|
|
16
|
+
const REPO = 'https://github.com/example/';
|
|
17
|
+
|
|
18
|
+
const ticket = (key, summary, status, category, assignee, hoursAgo, sinceHours, changedBy) => ({
|
|
19
|
+
key, summary, status, status_category: category, assignee, updated: ago(hoursAgo), url: `${SITE}${key}`,
|
|
20
|
+
status_change: sinceHours === undefined ? null : { since: ago(sinceHours), by: changedBy || assignee },
|
|
21
|
+
});
|
|
22
|
+
const comment = (id, key, hoursAgo, author, body) => ({ id, key, at: ago(hoursAgo), author, body, url: `${SITE}${key}?focusedCommentId=${id}` });
|
|
23
|
+
const pr = (number, key, title, repo, overrides = {}) => ({
|
|
24
|
+
pr: number, key, title, base: 'main', state: 'OPEN', review: '', checks: 'passing', merged_at: '', updated_at: ago(5), url: `${REPO}${repo}/pull/${number}`,
|
|
25
|
+
last_comment_by: '', last_comment_at: '', last_comment_url: '', last_comment: '', unanswered: false, ...overrides,
|
|
26
|
+
});
|
|
27
|
+
const activity = (hoursAgo, number, key, kind, who, repo, text, body) => ({ at: ago(hoursAgo), pr: `${repo}#${number}`, key, kind, who, url: `${REPO}${repo}/pull/${number}`, text, body: body || text });
|
|
28
|
+
const failedRun = (id, repo, workflow, hoursAgo, title) => ({
|
|
29
|
+
id, ref: `${repo}:${workflow}@main`, repo: `example/${repo}`, workflow, branch: 'main', failed: true, mine: true, actor: 'alex', title, url: `${REPO}${repo}/actions/runs/${id}`, at: ago(hoursAgo), streak: id,
|
|
30
|
+
});
|
|
31
|
+
const runActivity = (run) => ({ at: run.at, pr: run.ref, key: '', title: run.title, kind: 'check failed', who: run.workflow, url: run.url, text: `${run.workflow} on ${run.branch}`, body: '' });
|
|
32
|
+
const ask = (noteId, repo, number, title, key, kind, who, hoursAgo, text) => ({ id: `gha:${noteId}`, repo: `example/${repo}`, number, ref: `${repo}#${number}`, title, key, kind, who, at: ago(hoursAgo), url: `${REPO}${repo}/pull/${number}`, text, body: text });
|
|
33
|
+
|
|
34
|
+
const northwind = (polls) => {
|
|
35
|
+
const me = 'Alex Doe';
|
|
36
|
+
const tickets = [
|
|
37
|
+
ticket('NW-142', 'Rounds: due dates differ between locales', 'Passed', 'indeterminate', me, 1, 1, 'Sam Tester'),
|
|
38
|
+
ticket('NW-151', 'Export to CSV loses the header row on retry', 'In Progress', 'indeterminate', me, 3, 30),
|
|
39
|
+
ticket('NW-137', 'Login page: remember the last used organisation', polls >= 3 ? 'Ready for QA' : 'Code Review', 'indeterminate', polls >= 3 ? 'Sam Tester' : me, polls >= 3 ? 0.1 : 20, polls >= 3 ? 0.1 : 26, polls >= 3 ? me : undefined),
|
|
40
|
+
ticket('NW-171', 'Spike: search index on Postgres', 'In Progress', 'indeterminate', me, 9, 9),
|
|
41
|
+
ticket('NW-129', 'Audit log keeps the deleted user name', 'Ready for QA', 'indeterminate', 'Sam Tester', 30, 29, me),
|
|
42
|
+
ticket('NW-118', 'Nightly report email arrives twice', 'In QA', 'indeterminate', 'Sam Tester', 80, 79, me),
|
|
43
|
+
ticket('NW-104', 'Invoice PDF: wrong currency symbol for CHF', 'On Hold', 'indeterminate', me, 120, 118, 'Pat Owner'),
|
|
44
|
+
ticket('NW-160', 'Import wizard for legacy spreadsheets', 'Backlog', 'new', 'Unassigned', 200),
|
|
45
|
+
ticket('NW-099', 'Dashboard tiles: tooltip on hover', 'Done', 'done', me, 50),
|
|
46
|
+
ticket('NW-088', 'Rate limit on the search endpoint', 'Done', 'done', me, 140),
|
|
47
|
+
];
|
|
48
|
+
const comments = [
|
|
49
|
+
comment('c1', 'NW-142', 1, 'Sam Tester', 'Have tested this on staging and it is now QA passed. Please also test it in the customer org once merged.'),
|
|
50
|
+
comment('c2', 'NW-151', 4, 'Pat Owner', `@${me} can you confirm the retry keeps the original delimiter? The client asked twice.`),
|
|
51
|
+
comment('c3', 'NW-151', 6, me, '@Pat Owner yes, the delimiter comes from the first attempt; adding a test for it.'),
|
|
52
|
+
comment('c4', 'NW-151', 2, 'Pat Owner', `@${me} and one more: does it cover TSV as well?`),
|
|
53
|
+
comment('c5', 'NW-104', 118, 'Pat Owner', 'On hold: waiting for the vendor to confirm the CHF rounding rule, they promised an answer next week.'),
|
|
54
|
+
comment('c6', 'NW-104', 140, me, 'Reproduced with the sample invoice, fix is a one-liner once we know the rule.'),
|
|
55
|
+
comment('c7', 'NW-118', 70, 'Sam Tester', `@${me} fyi, moved to In QA, will run the nightly job twice tonight.`),
|
|
56
|
+
comment('c8', 'NW-129', 28, 'Automation for Jira', 'Transitioned by rule "Ready for QA when PR approved".'),
|
|
57
|
+
...(polls >= 3 ? [comment('c9', 'NW-137', 0.1, 'Sam Tester', `@${me} picked it up, first pass looks good, one question: should the choice survive logout?`)] : []),
|
|
58
|
+
];
|
|
59
|
+
const prs = [
|
|
60
|
+
pr(3978, 'NW-142', 'NW-142 fix week start per locale', 'northwind-web', { base: 'release/2.5', review: 'APPROVED', updated_at: ago(2), last_comment_by: 'Chris Reviewer', last_comment_at: ago(3), last_comment: 'Nice find on the locale changing the week start.', last_comment_url: `${REPO}northwind-web/pull/3978#issuecomment-1` }),
|
|
61
|
+
pr(3991, 'NW-151', 'NW-151 keep the header row on CSV retry', 'northwind-web', { checks: 'failing', updated_at: ago(1) }),
|
|
62
|
+
pr(3985, 'NW-137', 'NW-137 remember the last organisation on login', 'northwind-web', { review: polls >= 3 ? 'APPROVED' : 'CHANGES_REQUESTED', updated_at: ago(polls >= 3 ? 0.2 : 19), last_comment_by: 'Chris Reviewer', last_comment_at: ago(19), last_comment: 'Please do not store the org id in localStorage without a namespace.', last_comment_url: `${REPO}northwind-web/pull/3985#discussion_r1`, unanswered: polls < 3 }),
|
|
63
|
+
pr(3960, 'NW-129', 'NW-129 keep the user name in the audit log', 'northwind-web', { state: 'MERGED', merged_at: ago(31), updated_at: ago(31) }),
|
|
64
|
+
pr(3940, 'NW-099', 'NW-099 dashboard tile tooltips', 'northwind-web', { state: 'MERGED', merged_at: ago(52), updated_at: ago(52) }),
|
|
65
|
+
];
|
|
66
|
+
const acts = [
|
|
67
|
+
activity(1, 3991, 'NW-151', 'check failed', 'unit-tests', 'northwind-web', ''),
|
|
68
|
+
activity(3, 3978, 'NW-142', 'comment', 'Chris Reviewer', 'northwind-web', 'Nice find on the locale changing the week start.'),
|
|
69
|
+
activity(19, 3985, 'NW-137', 'changes requested', 'Chris Reviewer', 'northwind-web', 'Please do not store the org id in localStorage without a namespace.', 'Please do not store the org id in `localStorage` without a namespace.\n\nTwo tabs with two organisations would overwrite each other. A `nw:` prefix plus the user id is enough.'),
|
|
70
|
+
activity(31, 3960, 'NW-129', 'merged', '', 'northwind-web', ''),
|
|
71
|
+
activity(52, 3940, 'NW-099', 'merged', '', 'northwind-web', ''),
|
|
72
|
+
...(polls >= 3 ? [activity(0.2, 3985, 'NW-137', 'approved', 'Chris Reviewer', 'northwind-web', 'Namespace added, looks good now.')] : []),
|
|
73
|
+
];
|
|
74
|
+
const asks = [
|
|
75
|
+
ask('n1', 'northwind-api', 512, 'Paginate the audit endpoint', 'NW-133', 'review requested', 'Chris Reviewer', 7, 'Paginate the audit endpoint'),
|
|
76
|
+
ask('n2', 'northwind-infra', 77, 'Rotate the staging certificates', '', 'mentioned you', 'Pat Owner', 26, `@alex could you check whether the cron on staging still runs after the rotation?`),
|
|
77
|
+
];
|
|
78
|
+
const runs = [failedRun(9001, 'northwind-web', 'scout-publish', 0.5, 'Merge pull request #3940 from example/nw-099-tooltips')];
|
|
79
|
+
return { me, tickets, comments, prs, activity: [...acts, ...runs.map(runActivity)], asks, runs };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const atlas = (polls) => {
|
|
83
|
+
const me = 'Alex Doe';
|
|
84
|
+
const tickets = [
|
|
85
|
+
ticket('ATL-77', 'Offline mode: queue edits made without network', 'In Review', 'indeterminate', me, 10, 12),
|
|
86
|
+
ticket('ATL-81', 'Crash on rotating the map while loading', 'Testing', 'indeterminate', 'Jo Qa', 40, 36, me),
|
|
87
|
+
ticket('ATL-64', 'Dark theme for the trip summary', 'Blocked', 'indeterminate', me, 90, 88, 'Dana Lead'),
|
|
88
|
+
ticket('ATL-85', 'Accessibility labels on the bottom bar', 'Todo', 'indeterminate', me, 5),
|
|
89
|
+
ticket('ATL-59', 'Share a trip as a link', 'Done', 'done', me, 100),
|
|
90
|
+
ticket('ATL-90', 'Replace the deprecated map SDK', 'Backlog', 'new', 'Dana Lead', 300),
|
|
91
|
+
];
|
|
92
|
+
const comments = [
|
|
93
|
+
comment('a1', 'ATL-64', 88, 'Dana Lead', 'Blocked until design delivers the dark palette, see the Figma thread.'),
|
|
94
|
+
comment('a2', 'ATL-77', 9, 'Dana Lead', `@${me} please add a screenshot of the queue indicator to the PR description`),
|
|
95
|
+
comment('a3', 'ATL-81', 35, 'Jo Qa', 'Testing on the three devices now.'),
|
|
96
|
+
...(polls >= 4 ? [comment('a4', 'ATL-81', 0.1, 'Jo Qa', `@${me} reproduced once more on the small device, log attached, can you have a look?`)] : []),
|
|
97
|
+
];
|
|
98
|
+
const prs = [
|
|
99
|
+
pr(214, 'ATL-77', 'ATL-77 offline edit queue', 'atlas-mobile', { base: 'develop', updated_at: ago(9), last_comment_by: 'Dana Lead', last_comment_at: ago(9), last_comment: 'please add a screenshot of the queue indicator', last_comment_url: `${REPO}atlas-mobile/pull/214#issuecomment-3`, unanswered: true }),
|
|
100
|
+
pr(209, 'ATL-81', 'ATL-81 guard the map rotation during load', 'atlas-mobile', { base: 'develop', state: 'MERGED', merged_at: ago(41), updated_at: ago(41) }),
|
|
101
|
+
pr(198, 'ATL-59', 'ATL-59 share trip link', 'atlas-mobile', { base: 'develop', state: 'MERGED', merged_at: ago(101), updated_at: ago(101) }),
|
|
102
|
+
];
|
|
103
|
+
const acts = [
|
|
104
|
+
activity(9, 214, 'ATL-77', 'comment', 'Dana Lead', 'atlas-mobile', 'please add a screenshot of the queue indicator'),
|
|
105
|
+
activity(41, 209, 'ATL-81', 'merged', '', 'atlas-mobile', ''),
|
|
106
|
+
activity(101, 198, 'ATL-59', 'merged', '', 'atlas-mobile', ''),
|
|
107
|
+
];
|
|
108
|
+
return { me, tickets, comments, prs, activity: acts, asks: [] };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const garden = () => {
|
|
112
|
+
const me = 'alex';
|
|
113
|
+
const prs = [
|
|
114
|
+
pr(12, '', 'Water schedule: skip rainy days', 'garden-bot', { updated_at: ago(2), checks: 'running' }),
|
|
115
|
+
pr(11, '', 'Soil sensor calibration', 'garden-bot', { state: 'MERGED', merged_at: ago(30), updated_at: ago(30) }),
|
|
116
|
+
pr(9, '', 'Add the greenhouse zone', 'garden-bot', { state: 'MERGED', merged_at: ago(120), updated_at: ago(120) }),
|
|
117
|
+
];
|
|
118
|
+
const acts = [
|
|
119
|
+
activity(2, 12, '', 'comment', 'you', 'garden-bot', 'Rebased on the sensor change.'),
|
|
120
|
+
activity(30, 11, '', 'merged', '', 'garden-bot', ''),
|
|
121
|
+
activity(120, 9, '', 'merged', '', 'garden-bot', ''),
|
|
122
|
+
];
|
|
123
|
+
return { me, tickets: [], comments: [], prs, activity: acts, asks: [ask('g1', 'garden-bot', 13, 'Frost warning notifications', '', 'review requested', 'Sam Friend', 4, 'Frost warning notifications')] };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const SCENARIOS = { northwind, atlas, garden };
|
|
127
|
+
|
|
128
|
+
export const poll = async ({ settings, cache }) => {
|
|
129
|
+
const scenario = SCENARIOS[settings.scenario];
|
|
130
|
+
if (!scenario) {
|
|
131
|
+
throw new Error(`Unknown demo scenario "${settings.scenario}"; use northwind, atlas or garden`);
|
|
132
|
+
}
|
|
133
|
+
cache.polls = (cache.polls || 0) + 1;
|
|
134
|
+
cache.base = cache.base || Date.now();
|
|
135
|
+
base = cache.base;
|
|
136
|
+
const scenarioData = scenario(cache.polls);
|
|
137
|
+
const titles = new Map(scenarioData.prs.map((entry) => [entry.url, entry.title]));
|
|
138
|
+
const data = { runs: [], ...scenarioData, activity: scenarioData.activity.map((item) => ({ ...item, title: item.title || titles.get(item.url) || '' })) };
|
|
139
|
+
if (settings.part === 'tickets') {
|
|
140
|
+
return { me: data.me, tickets: data.tickets, comments: data.comments, ticket_base: SITE, errors: {} };
|
|
141
|
+
}
|
|
142
|
+
if (settings.part === 'code') {
|
|
143
|
+
return { me: data.me, prs: data.prs, activity: data.activity, asks: data.asks, runs: data.runs, errors: {} };
|
|
144
|
+
}
|
|
145
|
+
return { ...data, ticket_base: SITE, errors: {} };
|
|
146
|
+
};
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/** GitHub connector: your PRs in one repository with reviews, comments and checks, the workflow runs on its default branch, plus review requests and mentions across the org. Token from `GH_TOKEN`, else `gh auth token`. */
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { RateLimited } from '../lib/http-client.mjs';
|
|
5
|
+
import { oneline, nowIso, mapLimit, wordsRegex, DAY } from '../lib/text.mjs';
|
|
6
|
+
import { cachedToken } from '../lib/token-cache.mjs';
|
|
7
|
+
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
const API = 'https://api.github.com';
|
|
10
|
+
|
|
11
|
+
export const id = 'github';
|
|
12
|
+
export const label = 'GitHub';
|
|
13
|
+
export const links = (settings) => (settings.repo ? [{ label: 'GitHub', url: `https://github.com/${settings.repo}` }] : []);
|
|
14
|
+
export const fields = [
|
|
15
|
+
{ key: 'repo', label: 'Repository', hint: 'owner/name whose PRs are yours', required: true },
|
|
16
|
+
{ key: 'org', label: 'Organisation', hint: 'notifications are kept only from this org; defaults to the repository owner' },
|
|
17
|
+
{ key: 'me', label: 'Your login', hint: 'left empty, it is asked from the API once' },
|
|
18
|
+
{ key: 'account', label: 'gh account', hint: 'which logged-in gh account to use' },
|
|
19
|
+
{ key: 'bots', label: 'Bot logins', hint: 'comma-separated; their comments never count as a reply', list: true },
|
|
20
|
+
{ key: 'workflow_runs', label: 'Workflow runs', hint: 'watch the Actions runs on the default branch; on unless set to false', boolean: true, default: true },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export const persistedCache = ['prs', 'asks', 'runStreaks'];
|
|
24
|
+
|
|
25
|
+
/** `GH_TOKEN` when set (CI, a machine without gh), else the token gh holds. */
|
|
26
|
+
const ghToken = (account, cache) => cachedToken(cache, async () => {
|
|
27
|
+
if (process.env.GH_TOKEN) {
|
|
28
|
+
return process.env.GH_TOKEN;
|
|
29
|
+
}
|
|
30
|
+
const { stdout } = await run('gh', account ? ['auth', 'token', '--user', account] : ['auth', 'token']);
|
|
31
|
+
return stdout.trim();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const createApi = (http, token) => (path) => {
|
|
35
|
+
const url = path.startsWith(API) ? path : `${API}${path}`;
|
|
36
|
+
if (!url.startsWith(`${API}/`)) {
|
|
37
|
+
throw new Error(`Refusing to send the GitHub token to ${url}`);
|
|
38
|
+
}
|
|
39
|
+
return http.fetchJson(url, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } });
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const checksFor = async (api, repo, sha) => {
|
|
43
|
+
const runs = (await api(`/repos/${repo}/commits/${sha}/check-runs?per_page=100`)).check_runs || [];
|
|
44
|
+
const conclusions = runs.map((checkRun) => checkRun.conclusion || checkRun.status);
|
|
45
|
+
let state = 'passing';
|
|
46
|
+
if (conclusions.some((value) => ['failure', 'timed_out', 'cancelled', 'action_required'].includes(value))) {
|
|
47
|
+
state = 'failing';
|
|
48
|
+
} else if (conclusions.some((value) => ['queued', 'in_progress', 'pending', 'waiting', null, undefined].includes(value))) {
|
|
49
|
+
state = 'running';
|
|
50
|
+
} else if (!runs.length) {
|
|
51
|
+
state = 'none';
|
|
52
|
+
}
|
|
53
|
+
const failed = runs.filter((checkRun) => ['failure', 'timed_out'].includes(checkRun.conclusion));
|
|
54
|
+
return { state, failed };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const reviewDecision = (reviews) => {
|
|
58
|
+
const latest = {};
|
|
59
|
+
for (const review of reviews) {
|
|
60
|
+
if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) {
|
|
61
|
+
latest[review.user.login] = review.state;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const values = new Set(Object.values(latest));
|
|
65
|
+
if (values.has('CHANGES_REQUESTED')) {
|
|
66
|
+
return 'CHANGES_REQUESTED';
|
|
67
|
+
}
|
|
68
|
+
return values.has('APPROVED') ? 'APPROVED' : '';
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** A PR's reference in the feed: the repository name and the number, so a PR without a ticket still says where it lives (RMA-167). */
|
|
72
|
+
const prRef = (repo, number) => `${repo.split('/').pop()}#${number}`;
|
|
73
|
+
|
|
74
|
+
const failedCheckActivity = (checkRun, { pr, key, title }) => ({ at: checkRun.completed_at || nowIso(), pr, key, title, kind: 'check failed', who: checkRun.name, url: checkRun.html_url, text: '', body: '' });
|
|
75
|
+
|
|
76
|
+
const buildPr = async (api, { repo, me, bots, keyOf, sinceIso }, number) => {
|
|
77
|
+
const [pr, issueComments, inlineComments, reviews] = await Promise.all([
|
|
78
|
+
api(`/repos/${repo}/pulls/${number}`),
|
|
79
|
+
api(`/repos/${repo}/issues/${number}/comments?per_page=100`),
|
|
80
|
+
api(`/repos/${repo}/pulls/${number}/comments?per_page=100`),
|
|
81
|
+
api(`/repos/${repo}/pulls/${number}/reviews?per_page=100`),
|
|
82
|
+
]);
|
|
83
|
+
const state = pr.merged_at ? 'MERGED' : pr.state.toUpperCase();
|
|
84
|
+
const checks = await checksFor(api, repo, pr.head.sha);
|
|
85
|
+
const human = [];
|
|
86
|
+
for (const comment of issueComments) {
|
|
87
|
+
human.push({ who: comment.user.login, at: comment.created_at, url: comment.html_url, kind: 'comment', body: comment.body || '', state: '' });
|
|
88
|
+
}
|
|
89
|
+
for (const comment of inlineComments) {
|
|
90
|
+
human.push({ who: comment.user.login, at: comment.created_at, url: comment.html_url, kind: 'inline comment', body: comment.body || '', state: '' });
|
|
91
|
+
}
|
|
92
|
+
for (const review of reviews) {
|
|
93
|
+
if (review.state === 'COMMENTED' && !review.body) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
human.push({ who: review.user.login, at: review.submitted_at || '', url: review.html_url, kind: review.state.toLowerCase().replace(/_/g, ' '), body: review.body || '', state: review.state });
|
|
97
|
+
}
|
|
98
|
+
const people = human.filter((item) => !bots.test(item.who) && item.at).sort((a, b) => a.at.localeCompare(b.at));
|
|
99
|
+
const others = people.filter((item) => item.who !== me);
|
|
100
|
+
const mine = people.filter((item) => item.who === me);
|
|
101
|
+
const last = others[others.length - 1];
|
|
102
|
+
const myLast = mine.length ? mine[mine.length - 1].at : '';
|
|
103
|
+
const key = keyOf(pr.title);
|
|
104
|
+
const subject = { pr: prRef(repo, number), key, title: pr.title };
|
|
105
|
+
const row = {
|
|
106
|
+
pr: number, key, title: pr.title, base: pr.base.ref, state, review: reviewDecision(reviews), checks: checks.state, merged_at: pr.merged_at || '',
|
|
107
|
+
updated_at: pr.updated_at, url: pr.html_url,
|
|
108
|
+
last_comment_by: last ? last.who : '', last_comment_at: last ? last.at : '', last_comment_url: last ? last.url : '', last_comment: last ? oneline(last.body, 160) : '',
|
|
109
|
+
unanswered: Boolean(last && state === 'OPEN' && last.state !== 'APPROVED' && last.at > myLast),
|
|
110
|
+
};
|
|
111
|
+
const activity = people.filter((item) => item.at >= sinceIso).map((item) => ({
|
|
112
|
+
...subject, at: item.at, kind: item.kind, who: item.who === me ? 'you' : item.who, url: item.url, text: oneline(item.body), body: item.body.replace(/\r/g, '').slice(0, 6000),
|
|
113
|
+
}));
|
|
114
|
+
for (const checkRun of checks.failed) {
|
|
115
|
+
if ((checkRun.completed_at || '') >= sinceIso) {
|
|
116
|
+
activity.push(failedCheckActivity(checkRun, subject));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (pr.merged_at && pr.merged_at >= sinceIso) {
|
|
120
|
+
activity.push({ ...subject, at: pr.merged_at, kind: 'merged', who: '', url: pr.html_url, text: '', body: '' });
|
|
121
|
+
}
|
|
122
|
+
return { row, activity, sha: pr.head.sha };
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const refreshChecks = async (api, repo, cached, number) => {
|
|
126
|
+
const checks = await checksFor(api, repo, cached.sha);
|
|
127
|
+
if (checks.state === cached.row.checks) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
cached.row.checks = checks.state;
|
|
131
|
+
for (const checkRun of checks.failed) {
|
|
132
|
+
const item = failedCheckActivity(checkRun, { pr: prRef(repo, number), key: cached.row.key, title: cached.row.title });
|
|
133
|
+
if (!cached.activity.some((existing) => existing.url === item.url && existing.at === item.at)) {
|
|
134
|
+
cached.activity.push(item);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const pollPrs = async (api, context) => {
|
|
140
|
+
const { repo, me, sinceIso, cache } = context;
|
|
141
|
+
const since = sinceIso.slice(0, 10);
|
|
142
|
+
const found = new Map();
|
|
143
|
+
const queries = [`repo:${repo} is:pr author:${me} is:open`, `repo:${repo} is:pr author:${me} updated:>=${since}`];
|
|
144
|
+
const searches = await Promise.all(queries.map((query) => api(`/search/issues?q=${encodeURIComponent(query)}&per_page=50`)));
|
|
145
|
+
for (const item of searches.flatMap((result) => result.items || [])) {
|
|
146
|
+
found.set(item.number, item.updated_at);
|
|
147
|
+
}
|
|
148
|
+
cache.prs = cache.prs || new Map();
|
|
149
|
+
const unchanged = [...found].filter(([number, updatedAt]) => cache.prs.get(number)?.updated_at === updatedAt).map(([number]) => number);
|
|
150
|
+
const changed = [...found].filter(([number]) => !unchanged.includes(number));
|
|
151
|
+
await mapLimit(unchanged.filter((number) => cache.prs.get(number).row.state === 'OPEN'), 4, (number) => refreshChecks(api, repo, cache.prs.get(number), number));
|
|
152
|
+
await mapLimit(changed, 4, async ([number, updatedAt]) => {
|
|
153
|
+
cache.prs.set(number, { updated_at: updatedAt, ...(await buildPr(api, context, number)) });
|
|
154
|
+
});
|
|
155
|
+
for (const number of [...cache.prs.keys()]) {
|
|
156
|
+
if (!found.has(number)) {
|
|
157
|
+
cache.prs.delete(number);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const prs = [...cache.prs.values()].map((entry) => entry.row).sort((a, b) => a.pr - b.pr);
|
|
161
|
+
const activity = [...cache.prs.values()].flatMap((entry) => entry.activity).filter((item) => item.at >= sinceIso).sort((a, b) => b.at.localeCompare(a.at));
|
|
162
|
+
return { prs, activity };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const RUN_FAILED = ['failure', 'timed_out', 'startup_failure'];
|
|
166
|
+
const RUN_PASSED = ['success'];
|
|
167
|
+
|
|
168
|
+
/** A workflow run's reference in the feed and the key of its row: repository name, workflow and branch (RMA-152). */
|
|
169
|
+
const runRef = (repo, workflow, branch) => `${repo.split('/').pop()}:${workflow}@${branch}`;
|
|
170
|
+
|
|
171
|
+
const defaultBranch = async (api, repo, cache) => {
|
|
172
|
+
if (!cache.defaultBranch || Date.now() - cache.defaultBranchAt > DAY) {
|
|
173
|
+
cache.defaultBranch = (await api(`/repos/${repo}`)).default_branch;
|
|
174
|
+
cache.defaultBranchAt = Date.now();
|
|
175
|
+
}
|
|
176
|
+
return cache.defaultBranch;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** The completed runs on the default branch, newest first per workflow; pull request runs are left to the PR path. */
|
|
180
|
+
const runsByWorkflow = (listed, branch) => {
|
|
181
|
+
const byWorkflow = new Map();
|
|
182
|
+
const decided = listed.filter((run) => run.head_branch === branch && !String(run.event).startsWith('pull_request') && !(run.pull_requests || []).length
|
|
183
|
+
&& run.status === 'completed' && [...RUN_FAILED, ...RUN_PASSED].includes(run.conclusion));
|
|
184
|
+
for (const run of decided.sort((a, b) => b.created_at.localeCompare(a.created_at))) {
|
|
185
|
+
(byWorkflow.get(run.workflow_id) || byWorkflow.set(run.workflow_id, []).get(run.workflow_id)).push(run);
|
|
186
|
+
}
|
|
187
|
+
return byWorkflow;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** The first failure of the newest unbroken failure streak; a streak longer than the page keeps the start seen on an earlier poll. */
|
|
191
|
+
const streakStart = (runs, ref, cache) => {
|
|
192
|
+
const passedAt = runs.findIndex((run) => RUN_PASSED.includes(run.conclusion));
|
|
193
|
+
const failures = passedAt < 0 ? runs : runs.slice(0, passedAt);
|
|
194
|
+
const start = passedAt < 0 && cache.runStreaks.has(ref) ? cache.runStreaks.get(ref) : failures[failures.length - 1].id;
|
|
195
|
+
cache.runStreaks.set(ref, start);
|
|
196
|
+
return start;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const pollRuns = async (api, { repo, me, sinceIso, cache }) => {
|
|
200
|
+
const branch = await defaultBranch(api, repo, cache);
|
|
201
|
+
const listed = (await api(`/repos/${repo}/actions/runs?branch=${encodeURIComponent(branch)}&per_page=50`)).workflow_runs || [];
|
|
202
|
+
cache.runStreaks = cache.runStreaks || new Map();
|
|
203
|
+
const runs = [];
|
|
204
|
+
const activity = [];
|
|
205
|
+
for (const workflowRuns of runsByWorkflow(listed, branch).values()) {
|
|
206
|
+
const ref = runRef(repo, workflowRuns[0].name, branch);
|
|
207
|
+
workflowRuns.forEach((run, index) => {
|
|
208
|
+
const failed = RUN_FAILED.includes(run.conclusion);
|
|
209
|
+
const recovered = !failed && RUN_FAILED.includes(workflowRuns[index + 1]?.conclusion);
|
|
210
|
+
if ((failed || recovered) && run.updated_at >= sinceIso) {
|
|
211
|
+
activity.push({ at: run.updated_at, pr: ref, key: '', title: run.display_title || '', kind: failed ? 'check failed' : 'check passed', who: run.name, url: run.html_url, text: `${run.name} on ${branch}`, body: '' });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
const latest = workflowRuns[0];
|
|
215
|
+
if (!RUN_FAILED.includes(latest.conclusion)) {
|
|
216
|
+
cache.runStreaks.delete(ref);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
runs.push({
|
|
220
|
+
id: latest.id, ref, repo, workflow: latest.name, branch, failed: true, mine: [latest.actor?.login, latest.triggering_actor?.login].includes(me),
|
|
221
|
+
actor: latest.actor?.login || '', title: latest.display_title || '', url: latest.html_url, at: latest.updated_at, streak: streakStart(workflowRuns, ref, cache),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return { runs, activity };
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const issueNumber = (apiUrl) => {
|
|
228
|
+
const match = /\/(pulls|issues)\/(\d+)$/.exec(apiUrl || '');
|
|
229
|
+
return match ? Number(match[2]) : null;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const buildAsk = async (api, { me, keyOf }, note, repo) => {
|
|
233
|
+
const subject = note.subject;
|
|
234
|
+
const number = issueNumber(subject.url);
|
|
235
|
+
if (!number) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const title = subject.title || '';
|
|
239
|
+
const base = { repo, number, ref: prRef(repo, number), title, key: keyOf(title), url: `https://github.com/${repo}/${subject.type === 'PullRequest' ? 'pull' : 'issues'}/${number}` };
|
|
240
|
+
if (note.reason === 'review_requested' && subject.type === 'PullRequest') {
|
|
241
|
+
const pr = await api(`/repos/${repo}/pulls/${number}`);
|
|
242
|
+
if (pr.state !== 'open') {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const requested = await api(`/repos/${repo}/pulls/${number}/requested_reviewers`);
|
|
246
|
+
if (!(requested.users || []).some((user) => user.login === me)) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
return { ...base, id: `gha:${note.id}`, kind: 'review requested', who: pr.user.login, at: note.updated_at, text: oneline(title), body: (pr.body || '').slice(0, 6000) };
|
|
250
|
+
}
|
|
251
|
+
let comment = subject.latest_comment_url ? await api(subject.latest_comment_url) : null;
|
|
252
|
+
const comments = await api(`/repos/${repo}/issues/${number}/comments?per_page=100`);
|
|
253
|
+
if (!comment || !(comment.body || '').includes(`@${me}`)) {
|
|
254
|
+
const hits = comments.filter((candidate) => (candidate.body || '').includes(`@${me}`));
|
|
255
|
+
comment = hits.length ? hits[hits.length - 1] : comment;
|
|
256
|
+
}
|
|
257
|
+
if (!comment) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
const at = comment.created_at || note.updated_at;
|
|
261
|
+
if (comments.some((candidate) => candidate.user.login === me && candidate.created_at > at)) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
return { ...base, id: `gha:${note.id}`, kind: 'mentioned you', who: comment.user.login, at, url: comment.html_url || base.url, text: oneline(comment.body || ''), body: (comment.body || '').replace(/\r/g, '').slice(0, 6000) };
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const pollAsks = async (api, context) => {
|
|
268
|
+
const { org, cache } = context;
|
|
269
|
+
const notes = await api('/notifications?participating=true&all=false&per_page=50');
|
|
270
|
+
cache.asks = cache.asks || new Map();
|
|
271
|
+
const rows = [];
|
|
272
|
+
const seen = new Set();
|
|
273
|
+
const relevant = notes.filter((note) => note.repository.full_name.startsWith(`${org}/`) && ['review_requested', 'mention', 'team_mention', 'assign'].includes(note.reason));
|
|
274
|
+
await mapLimit(relevant, 4, async (note) => {
|
|
275
|
+
seen.add(note.id);
|
|
276
|
+
const cached = cache.asks.get(note.id);
|
|
277
|
+
const row = cached && cached.updated_at === note.updated_at ? cached.row : await buildAsk(api, context, note, note.repository.full_name);
|
|
278
|
+
cache.asks.set(note.id, { updated_at: note.updated_at, row });
|
|
279
|
+
if (row) {
|
|
280
|
+
rows.push(row);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
for (const noteId of [...cache.asks.keys()]) {
|
|
284
|
+
if (!seen.has(noteId)) {
|
|
285
|
+
cache.asks.delete(noteId);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return rows;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
export const poll = async ({ settings, http, cache, sinceIso, keyOf }) => {
|
|
292
|
+
const api = createApi(http, await ghToken(settings.account, cache));
|
|
293
|
+
const me = settings.me || (cache.me ??= (await api('/user')).login);
|
|
294
|
+
const context = { repo: settings.repo, org: settings.org || settings.repo.split('/')[0], me, bots: wordsRegex(settings.bots || ['github-actions', 'dependabot', '[bot]']), keyOf, sinceIso, cache };
|
|
295
|
+
const watchRuns = settings.workflow_runs !== false;
|
|
296
|
+
const [prsOutcome, asksOutcome, runsOutcome] = await Promise.allSettled([pollPrs(api, context), pollAsks(api, context), watchRuns ? pollRuns(api, context) : { runs: [], activity: [] }]);
|
|
297
|
+
const errors = {};
|
|
298
|
+
for (const [part, outcome] of /** @type {[string, PromiseSettledResult<unknown>][]} */ ([['pulls', prsOutcome], ['notifications', asksOutcome], ['runs', runsOutcome]])) {
|
|
299
|
+
if (outcome.status === 'rejected') {
|
|
300
|
+
if (outcome.reason instanceof RateLimited) {
|
|
301
|
+
throw outcome.reason;
|
|
302
|
+
}
|
|
303
|
+
errors[part] = `${outcome.reason.name}: ${outcome.reason.message}`;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const last = cache.last || { prs: [], prActivity: [], asks: [], runs: [], runActivity: [] };
|
|
307
|
+
cache.last = {
|
|
308
|
+
prs: prsOutcome.status === 'fulfilled' ? prsOutcome.value.prs : last.prs,
|
|
309
|
+
prActivity: prsOutcome.status === 'fulfilled' ? prsOutcome.value.activity : last.prActivity,
|
|
310
|
+
asks: asksOutcome.status === 'fulfilled' ? asksOutcome.value : last.asks,
|
|
311
|
+
runs: runsOutcome.status === 'fulfilled' ? runsOutcome.value.runs : last.runs,
|
|
312
|
+
runActivity: runsOutcome.status === 'fulfilled' ? runsOutcome.value.activity : last.runActivity,
|
|
313
|
+
};
|
|
314
|
+
const { prs, prActivity, asks, runs, runActivity } = cache.last;
|
|
315
|
+
return { me, prs, asks, runs, activity: [...prActivity, ...runActivity].sort((a, b) => b.at.localeCompare(a.at)), errors };
|
|
316
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** The connector registry: every source type the server knows, and the catalogue the settings form and Claude read. */
|
|
2
|
+
import * as github from './github.mjs';
|
|
3
|
+
import * as jira from './jira.mjs';
|
|
4
|
+
import * as demo from './demo.mjs';
|
|
5
|
+
import * as linear from './linear.mjs';
|
|
6
|
+
|
|
7
|
+
export const CONNECTORS = { github, jira, linear, demo };
|
|
8
|
+
|
|
9
|
+
/** What a workspace calls each of its connectors: the entry's own label, else its id when another entry has the same type, else the type's label (RMA-167). */
|
|
10
|
+
export const connectorSummaries = (entries) => entries.map((entry) => {
|
|
11
|
+
const shared = entries.some((other) => other !== entry && other.type === entry.type);
|
|
12
|
+
return { id: entry.id, type: entry.type, label: entry.label || (shared ? entry.id : CONNECTORS[entry.type]?.label || entry.type) };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const connectorCatalogue = () => Object.values(CONNECTORS).map((connector) => ({ type: connector.id, label: connector.label, fields: connector.fields }));
|
|
16
|
+
|
|
17
|
+
/** The triples a stored secret is bound to: every secret field's service and account plus the connector's endpoint. */
|
|
18
|
+
const secretBindings = (entry) => (CONNECTORS[entry.type]?.fields || []).filter((field) => field.secret).map((field) => {
|
|
19
|
+
const target = secretTarget(entry, field.key);
|
|
20
|
+
return target ? { key: field.key, service: target.service, account: target.account, endpoint: String(entry[CONNECTORS[entry.type].endpointKey] || '') } : null;
|
|
21
|
+
}).filter(Boolean);
|
|
22
|
+
|
|
23
|
+
/** Secret fields of `entry` that must come with a fresh token because their service, account and endpoint triple is not already in the current config. */
|
|
24
|
+
export const secretsRequired = (currentEntries, entry) => {
|
|
25
|
+
const known = new Set(currentEntries.flatMap(secretBindings).map((binding) => `${binding.service}\n${binding.account}\n${binding.endpoint}`));
|
|
26
|
+
return secretBindings(entry).filter((binding) => !known.has(`${binding.service}\n${binding.account}\n${binding.endpoint}`)).map((binding) => binding.key);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Resolves where a secret field of one connector entry is stored: the service and the account to file it under. */
|
|
30
|
+
export const secretTarget = (entry, key) => {
|
|
31
|
+
const field = (CONNECTORS[entry.type]?.fields || []).find((candidate) => candidate.key === key && candidate.secret);
|
|
32
|
+
if (!field) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const account = String(entry[field.secret.account] || '').trim();
|
|
36
|
+
const service = String(entry[field.secret.service] || field.secret.default || '').trim();
|
|
37
|
+
return account && service ? { service, account } : null;
|
|
38
|
+
};
|