whose-turn 0.0.0-stage → 1.0.0-alpha.2
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 +18 -0
- package/LICENSE +21 -0
- package/README.md +241 -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-puYdOas_.js +1 -0
- package/dist/assets/index-DR8StBp8.js +3 -0
- package/dist/assets/index-d0wlPvUz.css +1 -0
- package/dist/assets/report-Xynr6yGL.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 +399 -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 +53 -0
- package/lib/facts.mjs +128 -0
- package/lib/feedback.mjs +145 -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/window.mjs +33 -0
- package/lib/workspace.mjs +294 -0
- package/open.sh +40 -0
- package/package.json +67 -4
- package/server.mjs +80 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** Jira Cloud connector: your tickets by JQL with all comments (ADF turned into text) and the changelog entry for the current status. Token from the secret store. */
|
|
2
|
+
import { toIsoUtc, mapLimit } from '../lib/text.mjs';
|
|
3
|
+
import { cachedToken } from '../lib/token-cache.mjs';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_SERVICE = 'whose-turn-jira';
|
|
6
|
+
|
|
7
|
+
export const id = 'jira';
|
|
8
|
+
export const label = 'Jira';
|
|
9
|
+
export const links = (settings) => (settings.base ? [{ label: 'Jira', url: String(settings.base).replace(/\/$/, '') }] : []);
|
|
10
|
+
export const endpointKey = 'base';
|
|
11
|
+
export const fields = [
|
|
12
|
+
{ key: 'base', label: 'Site', hint: 'https://your-site.atlassian.net', required: true },
|
|
13
|
+
{ key: 'email', label: 'Account email', hint: 'the account the API token belongs to', required: true },
|
|
14
|
+
{ key: 'token', label: 'API token', hint: 'stored in the secret store, never in the config', secret: { account: 'email', service: 'token_service', default: DEFAULT_SERVICE } },
|
|
15
|
+
{ key: 'token_service', label: 'Secret entry', hint: 'name of the secret-store entry; default whose-turn-jira' },
|
|
16
|
+
{ key: 'jql', label: 'JQL', hint: 'which tickets to watch; leave empty for the default' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const SIMPLE_MARKS = { code: '`', strong: '**' };
|
|
20
|
+
|
|
21
|
+
export const adfToText = (node, meId, meName) => {
|
|
22
|
+
if (!node) {
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
const type = node.type;
|
|
26
|
+
const children = node.content || [];
|
|
27
|
+
const inner = (separator = '') => children.map((child) => adfToText(child, meId, meName)).join(separator);
|
|
28
|
+
if (type === 'text') {
|
|
29
|
+
let text = node.text || '';
|
|
30
|
+
for (const mark of node.marks || []) {
|
|
31
|
+
if (SIMPLE_MARKS[mark.type]) {
|
|
32
|
+
text = `${SIMPLE_MARKS[mark.type]}${text}${SIMPLE_MARKS[mark.type]}`;
|
|
33
|
+
} else if (mark.type === 'link') {
|
|
34
|
+
const href = (mark.attrs || {}).href || '';
|
|
35
|
+
text = href && href !== text ? `[${text}](${href})` : text;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
if (type === 'mention') {
|
|
41
|
+
const attrs = node.attrs || {};
|
|
42
|
+
return attrs.id === meId ? `@${meName}` : attrs.text || '@someone';
|
|
43
|
+
}
|
|
44
|
+
if (type === 'hardBreak') {
|
|
45
|
+
return '\n';
|
|
46
|
+
}
|
|
47
|
+
if (type === 'paragraph') {
|
|
48
|
+
return `${inner()}\n\n`;
|
|
49
|
+
}
|
|
50
|
+
if (type === 'heading') {
|
|
51
|
+
return `${'#'.repeat(Number((node.attrs || {}).level || 2))} ${inner()}\n\n`;
|
|
52
|
+
}
|
|
53
|
+
if (type === 'codeBlock') {
|
|
54
|
+
return `\`\`\`\n${inner()}\n\`\`\`\n\n`;
|
|
55
|
+
}
|
|
56
|
+
if (type === 'blockquote') {
|
|
57
|
+
return `${inner().trim().split('\n').map((line) => `> ${line}`).join('\n')}\n\n`;
|
|
58
|
+
}
|
|
59
|
+
if (type === 'bulletList' || type === 'orderedList') {
|
|
60
|
+
return `${children.map((item, index) => `${type === 'orderedList' ? `${index + 1}.` : '-'} ${adfToText(item, meId, meName).trim().replace(/\n\n/g, '\n')}`).join('\n')}\n\n`;
|
|
61
|
+
}
|
|
62
|
+
if (['mediaSingle', 'mediaGroup', 'media', 'mediaInline'].includes(type)) {
|
|
63
|
+
return '[image]';
|
|
64
|
+
}
|
|
65
|
+
if (type === 'inlineCard' || type === 'blockCard') {
|
|
66
|
+
return (node.attrs || {}).url || '';
|
|
67
|
+
}
|
|
68
|
+
if (type === 'emoji') {
|
|
69
|
+
return (node.attrs || {}).text || '';
|
|
70
|
+
}
|
|
71
|
+
return inner();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const createApi = (http, base, email, token) => {
|
|
75
|
+
const auth = Buffer.from(`${email}:${token}`).toString('base64');
|
|
76
|
+
return (path, body) => http.fetchJson(`${base}${path}`, { method: body === undefined ? 'GET' : 'POST', body, useEtag: false, headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' } });
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const statusEntered = async (api, cache, key, updated, status) => {
|
|
80
|
+
const cached = cache.get(key);
|
|
81
|
+
if (cached && cached.updated === updated) {
|
|
82
|
+
return cached.info;
|
|
83
|
+
}
|
|
84
|
+
let info = null;
|
|
85
|
+
let start = 0;
|
|
86
|
+
for (;;) {
|
|
87
|
+
const page = await api(`/rest/api/3/issue/${key}/changelog?startAt=${start}&maxResults=100`);
|
|
88
|
+
for (const history of page.values || []) {
|
|
89
|
+
for (const item of history.items || []) {
|
|
90
|
+
if (item.field === 'status' && String(item.toString || '').toLowerCase() === status.toLowerCase()) {
|
|
91
|
+
const at = toIsoUtc(history.created);
|
|
92
|
+
if (!info || at > info.since) {
|
|
93
|
+
info = { since: at, by: (history.author || {}).displayName || '' };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (page.isLast !== false || !(page.values || []).length) {
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
start += page.values.length;
|
|
102
|
+
}
|
|
103
|
+
cache.set(key, { updated, info });
|
|
104
|
+
return info;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const jiraToken = async (settings, secrets, cache) => {
|
|
108
|
+
const service = settings.token_service || DEFAULT_SERVICE;
|
|
109
|
+
const token = await cachedToken(cache, () => secrets.get(service, settings.email));
|
|
110
|
+
if (!token) {
|
|
111
|
+
throw new Error(`No Jira API token in the secret store (service ${service}, account ${settings.email})`);
|
|
112
|
+
}
|
|
113
|
+
return token;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const poll = async ({ settings, http, secrets, cache }) => {
|
|
117
|
+
const base = String(settings.base).replace(/\/$/, '');
|
|
118
|
+
if (!base.startsWith('https://')) {
|
|
119
|
+
throw new Error('jira.base must start with https://');
|
|
120
|
+
}
|
|
121
|
+
const api = createApi(http, base, settings.email, await jiraToken(settings, secrets, cache));
|
|
122
|
+
cache.me ??= await api('/rest/api/3/myself');
|
|
123
|
+
const meId = cache.me.accountId;
|
|
124
|
+
const meName = cache.me.displayName || 'me';
|
|
125
|
+
const jql = settings.jql || '(assignee = currentUser() AND statusCategory != Done) OR (assignee was currentUser() AND updated >= -30d) ORDER BY updated DESC';
|
|
126
|
+
const result = await api('/rest/api/3/search/jql', { jql, maxResults: 100, fields: ['summary', 'status', 'assignee', 'updated', 'comment'] });
|
|
127
|
+
const tickets = [];
|
|
128
|
+
const comments = [];
|
|
129
|
+
for (const issue of result.issues || []) {
|
|
130
|
+
const fields = issue.fields;
|
|
131
|
+
const url = `${base}/browse/${issue.key}`;
|
|
132
|
+
tickets.push({
|
|
133
|
+
key: issue.key, summary: fields.summary || '', status: (fields.status || {}).name || '', status_category: ((fields.status || {}).statusCategory || {}).key || '',
|
|
134
|
+
assignee: (fields.assignee || {}).displayName || 'Unassigned', updated: fields.updated || '', url,
|
|
135
|
+
});
|
|
136
|
+
for (const comment of ((fields.comment || {}).comments || [])) {
|
|
137
|
+
const body = comment.body;
|
|
138
|
+
comments.push({
|
|
139
|
+
id: comment.id, key: issue.key, at: toIsoUtc(comment.created), author: (comment.author || {}).displayName || '',
|
|
140
|
+
body: body && typeof body === 'object' ? adfToText(body, meId, meName).trim() : String(body || ''), url: `${url}?focusedCommentId=${comment.id}`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
cache.statusEntered ??= new Map();
|
|
145
|
+
const errors = {};
|
|
146
|
+
await mapLimit(tickets.filter((ticket) => ticket.status_category !== 'done'), 4, async (ticket) => {
|
|
147
|
+
try {
|
|
148
|
+
ticket.status_change = await statusEntered(api, cache.statusEntered, ticket.key, ticket.updated, ticket.status);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
ticket.status_change = null;
|
|
151
|
+
errors.changelog = `${error.name}: ${error.message}`;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
return { me: meName, tickets, comments, ticket_base: `${base}/browse/`, errors };
|
|
155
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Linear connector: your issues in one workspace (optionally one team or project) with comments and the moment they entered their state. API key from the secret store. */
|
|
2
|
+
import { toIsoUtc } from '../lib/text.mjs';
|
|
3
|
+
import { linearQuery } from '../lib/linear-api.mjs';
|
|
4
|
+
import { cachedToken } from '../lib/token-cache.mjs';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SERVICE = 'whose-turn-linear';
|
|
7
|
+
|
|
8
|
+
export const id = 'linear';
|
|
9
|
+
export const label = 'Linear';
|
|
10
|
+
export const links = (settings) => (settings.workspace ? [{ label: 'Linear', url: `https://linear.app/${settings.workspace}${settings.team ? `/team/${settings.team}` : ''}` }] : []);
|
|
11
|
+
export const fields = [
|
|
12
|
+
{ key: 'workspace', label: 'Workspace slug', hint: 'the part after linear.app/ in your issue links', required: true },
|
|
13
|
+
{ key: 'api_key', label: 'API key', hint: 'a personal API key, stored in the secret store, never in the config', secret: { account: 'workspace', service: 'token_service', default: DEFAULT_SERVICE } },
|
|
14
|
+
{ key: 'token_service', label: 'Secret entry', hint: 'name of the secret-store entry; default whose-turn-linear' },
|
|
15
|
+
{ key: 'team', label: 'Team key', hint: 'only issues of this team, for example TEAM; empty for all' },
|
|
16
|
+
{ key: 'project', label: 'Project', hint: 'only issues of this project, by name; empty for all' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const QUERY = `
|
|
20
|
+
query Mine($filter: IssueFilter!, $after: String) {
|
|
21
|
+
viewer { id name displayName }
|
|
22
|
+
issues(filter: $filter, first: 50, after: $after, orderBy: updatedAt) {
|
|
23
|
+
pageInfo { hasNextPage endCursor }
|
|
24
|
+
nodes {
|
|
25
|
+
id identifier title url updatedAt completedAt
|
|
26
|
+
state { name type }
|
|
27
|
+
assignee { id name displayName }
|
|
28
|
+
comments(first: 50) { nodes { id body createdAt url user { id name displayName } } }
|
|
29
|
+
history(first: 25) { nodes { createdAt toState { name } actor { name displayName } } }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}`;
|
|
33
|
+
|
|
34
|
+
const CATEGORY = { completed: 'done', canceled: 'done', started: 'indeterminate', backlog: 'new', unstarted: 'new', triage: 'new' };
|
|
35
|
+
|
|
36
|
+
const mentionText = (body) => String(body || '').replace(/\[@?([^\]]+)\]\(https:\/\/linear\.app\/[^)]*\/profiles\/[^)]*\)/g, '@$1');
|
|
37
|
+
|
|
38
|
+
const stateEntered = (issue) => {
|
|
39
|
+
const entries = (issue.history?.nodes || []).filter((entry) => entry.toState && entry.toState.name === issue.state?.name);
|
|
40
|
+
if (!entries.length) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const latest = entries.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0];
|
|
44
|
+
return { since: toIsoUtc(latest.createdAt), by: latest.actor?.displayName || latest.actor?.name || '' };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const buildFilter = (settings, done) => {
|
|
48
|
+
const filter = { assignee: { isMe: { eq: true } } };
|
|
49
|
+
if (done) {
|
|
50
|
+
filter.completedAt = { gte: new Date(Date.now() - 30 * 86400000).toISOString() };
|
|
51
|
+
} else {
|
|
52
|
+
filter.state = { type: { nin: ['completed', 'canceled'] } };
|
|
53
|
+
}
|
|
54
|
+
if (settings.team) {
|
|
55
|
+
filter.team = { key: { eq: settings.team } };
|
|
56
|
+
}
|
|
57
|
+
if (settings.project) {
|
|
58
|
+
filter.project = { name: { eqIgnoreCase: settings.project } };
|
|
59
|
+
}
|
|
60
|
+
return filter;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const apiKey = async (settings, secrets, cache) => {
|
|
64
|
+
const service = settings.token_service || DEFAULT_SERVICE;
|
|
65
|
+
const token = await cachedToken(cache, () => secrets.get(service, settings.workspace));
|
|
66
|
+
if (!token) {
|
|
67
|
+
throw new Error(`No Linear API key in the secret store (service ${service}, account ${settings.workspace})`);
|
|
68
|
+
}
|
|
69
|
+
return token;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const fetchAll = async (http, key, filter) => {
|
|
73
|
+
const nodes = [];
|
|
74
|
+
let after = null;
|
|
75
|
+
let viewer = null;
|
|
76
|
+
for (let page = 0; page < 10; page += 1) {
|
|
77
|
+
const data = await linearQuery(http, key, QUERY, { filter, after });
|
|
78
|
+
viewer = data.viewer;
|
|
79
|
+
nodes.push(...data.issues.nodes);
|
|
80
|
+
if (!data.issues.pageInfo.hasNextPage) {
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
after = data.issues.pageInfo.endCursor;
|
|
84
|
+
}
|
|
85
|
+
return { viewer, nodes };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const normalise = (issues, viewer) => {
|
|
89
|
+
const me = viewer.displayName || viewer.name || '';
|
|
90
|
+
const tickets = [];
|
|
91
|
+
const comments = [];
|
|
92
|
+
for (const issue of issues) {
|
|
93
|
+
tickets.push({
|
|
94
|
+
key: issue.identifier, summary: issue.title, status: issue.state?.name || '', status_category: CATEGORY[issue.state?.type] || 'indeterminate',
|
|
95
|
+
assignee: issue.assignee?.displayName || issue.assignee?.name || 'Unassigned', updated: issue.updatedAt, url: issue.url,
|
|
96
|
+
status_change: issue.state?.type === 'completed' ? null : stateEntered(issue),
|
|
97
|
+
});
|
|
98
|
+
for (const comment of issue.comments?.nodes || []) {
|
|
99
|
+
comments.push({ id: comment.id, key: issue.identifier, at: toIsoUtc(comment.createdAt), author: comment.user?.displayName || comment.user?.name || '', body: mentionText(comment.body), url: comment.url || issue.url });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const base = tickets[0] ? tickets[0].url.replace(/\/issue\/[^/]+.*$/, '/issue/') : '';
|
|
103
|
+
return { me, tickets, comments, ticket_base: base };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const poll = async ({ settings, http, secrets, cache }) => {
|
|
107
|
+
const key = await apiKey(settings, secrets, cache);
|
|
108
|
+
const [open, done] = await Promise.all([fetchAll(http, key, buildFilter(settings, false)), fetchAll(http, key, buildFilter(settings, true))]);
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
const issues = [...open.nodes, ...done.nodes].filter((issue) => !seen.has(issue.id) && seen.add(issue.id));
|
|
111
|
+
const result = normalise(issues, open.viewer || done.viewer || {});
|
|
112
|
+
return { ...result, ticket_base: result.ticket_base || `https://linear.app/${settings.workspace}/issue/`, errors: {} };
|
|
113
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--page-bg: #f3f4f8;--tip-bg: #1f2433;--tip-ink: #e8ecf5;--tip-edge: rgb(255 255 255 / 12%);--tip-shadow: rgb(0 0 0 / 45%)}@media(prefers-color-scheme:dark){:root{--page-bg: #0d0f13}}@media(prefers-color-scheme:light){:root{--tip-bg: #fff;--tip-ink: #1d2023;--tip-edge: #d3d6de;--tip-shadow: rgb(0 0 0 / 18%)}}.app{--md-primary: #2c5697;--md-on-primary: #fff;--md-primary-container: #d8e2ff;--md-on-primary-container: #001a41;--md-secondary-container: #dae2f9;--md-on-secondary-container: #131c2b;--md-tertiary-container: #c8f0ec;--md-on-tertiary-container: #00201e;--md-warn-container: #ffdea6;--md-on-warn-container: #271900;--md-error: #ba1a1a;--md-error-container: #ffdad6;--md-on-error-container: #410002;--md-surface: #f9f9ff;--md-s-low: #fff;--md-s: #f1f3f9;--md-s-high: #e7e9f0;--md-s-highest: #e0e2ea;--md-on-surface: #191c20;--md-on-surface-variant: #44474e;--md-outline: #74777f;--md-outline-variant: #d3d6de;--md-state: 25, 28, 32;--state-hover: rgb(var(--md-state), .08);--focus-ring: 2px solid var(--md-primary);--glass: 255, 255, 255;--glass-a: .52;--glass-edge: rgb(255 255 255 / 70%);--glass-hi: rgb(255 255 255 / 90%);--blob-1: rgb(44 86 151 / 30%);--blob-2: rgb(0 169 224 / 22%);--blob-3: rgb(23 223 220 / 16%);--md-font: roboto, inter, -apple-system, blinkmacsystemfont, "Segoe UI", system-ui, sans-serif;--md-r-card: 12px;--md-r-chip: 8px;--new: #5cc8f0;--new-bg: rgb(92 200 240 / 17%);--new-ink: #04222e;--new-edge: rgb(92 200 240 / 28%);--new-glow: rgb(92 200 240 / 45%);--move: #e0a030;--live-on: #2bb673;--live-off: #d6455d;--live-warn: #e0a030;--live-pulse-from: rgb(43 182 115 / 50%);--live-pulse-to: rgb(43 182 115 / 0%);--live-pulse-rest: rgb(43 182 115 / 60%)}@media(prefers-color-scheme:dark){.app{--md-primary: #adc6ff;--md-on-primary: #0f2f60;--md-primary-container: #2b4678;--md-on-primary-container: #d8e2ff;--md-secondary-container: #3e4759;--md-on-secondary-container: #dae2f9;--md-tertiary-container: #1f504c;--md-on-tertiary-container: #b4efe9;--md-warn-container: #5c4300;--md-on-warn-container: #ffdea6;--md-error: #ffb4ab;--md-error-container: #93000a;--md-on-error-container: #ffdad6;--md-surface: #111318;--md-s-low: #1b1e23;--md-s: #1f2227;--md-s-high: #292c32;--md-s-highest: #34373d;--md-on-surface: #e2e2e9;--md-on-surface-variant: #c4c6d0;--md-outline: #8e9099;--md-outline-variant: #3a3d44;--md-state: 226, 226, 233;--glass: 30, 34, 42;--glass-a: .55;--glass-edge: rgb(255 255 255 / 9%);--glass-hi: rgb(255 255 255 / 6%);--blob-1: rgb(44 86 151 / 55%);--blob-2: rgb(0 169 224 / 28%);--blob-3: rgb(23 223 220 / 14%)}}:root,.page,.page__mount,.app{height:100%;margin:0}:where(.page) button{box-sizing:content-box;margin:0;padding:0;border:0;background:none;color:inherit;font:inherit;letter-spacing:inherit;word-spacing:inherit;text-align:inherit;text-transform:inherit;text-indent:inherit;text-shadow:inherit;-webkit-appearance:none;-moz-appearance:none;appearance:none}.page{background:var(--page-bg);overflow:hidden}.page--scroll{overflow:auto}.dashboard{position:relative;isolation:isolate;box-sizing:border-box;display:grid;gap:12px;padding:14px;border-radius:20px;font-family:var(--md-font);color:var(--md-on-surface);font-size:14px;line-height:1.43;letter-spacing:.01em}.dashboard:before{content:"";position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;border-radius:20px;pointer-events:none;background:radial-gradient(60% 55% at 12% 8%,var(--blob-1),transparent 70%),radial-gradient(45% 50% at 92% 30%,var(--blob-2),transparent 70%),radial-gradient(55% 60% at 55% 105%,var(--blob-3),transparent 70%)}.dashboard__backdrop{position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;width:100%;height:100%;border-radius:20px;pointer-events:none}.dashboard a{color:var(--md-primary);text-decoration:none}.dashboard a:hover{text-decoration:underline;text-underline-offset:3px}.dashboard a:focus-visible{outline:var(--focus-ring);outline-offset:2px;border-radius:4px}.dashboard a.btn{text-decoration:none}.dashboard__layout{display:grid;gap:12px;grid-template-columns:minmax(0,2.2fr) minmax(0,1fr);align-items:start}.dashboard__main{display:grid;gap:12px;min-width:0}.dashboard__feed{position:sticky;top:12px;max-height:calc(100vh - 24px);display:flex;flex-direction:column}@media(min-width:901px){.dashboard.dashboard--fit{height:var(--dashboard-height, calc(100vh - 120px) );grid-template-rows:auto minmax(0,1fr);overflow:hidden}.dashboard--fit .dashboard__layout{min-height:0;height:100%;align-items:stretch}.dashboard--fit .dashboard__main{display:flex;flex-direction:column;gap:12px;min-height:0}.dashboard--fit .dashboard__main>.island{display:flex;flex-direction:column;min-height:0}.dashboard--fit .dashboard__move{flex:0 1 auto;max-height:60%}.dashboard--fit .dashboard__todo{flex:0 0 auto;max-height:30%}.dashboard--fit .dashboard__waiting{flex:1 1 0}.dashboard--fit .dashboard__waiting:not(.island--collapsed){min-height:5rem}.dashboard--fit .dashboard__feed{position:static;max-height:none;height:100%;min-height:0}}.dashboard--fit .dashboard__main:has(.dashboard__waiting.island--collapsed) .dashboard__move:not(.island--collapsed){max-height:none;flex:1 1 0}.dashboard--fit .dashboard__main:has(.dashboard__waiting.island--collapsed):has(.dashboard__move.island--collapsed) .dashboard__todo:not(.island--collapsed){max-height:none;flex:1 1 0}.dashboard--fit .dashboard__main>.island--collapsed{flex:0 0 auto;max-height:none}.dashboard--fit .dashboard__feed.island--collapsed{height:auto;align-self:start}@media(max-width:900px){.page{overflow:auto}.dashboard__layout{grid-template-columns:1fr}.dashboard__feed{position:static;max-height:none}}.island{min-width:0;overflow:hidden;border:1px solid var(--glass-edge);border-radius:var(--md-r-card);background:rgb(var(--glass),var(--glass-a));-webkit-backdrop-filter:blur(8px) saturate(150%);backdrop-filter:blur(8px) saturate(150%);box-shadow:inset 0 1px 0 var(--glass-hi),0 8px 24px #0000002e}.fold-toggle{flex:none;display:inline-grid;place-items:center;width:24px;height:24px;margin-left:6px;border-radius:50%;cursor:pointer;color:var(--md-on-surface-variant);font-size:18px;line-height:1;transition:transform .15s ease}.fold-toggle:hover{background:var(--state-hover)}.fold-toggle--folded{transform:rotate(-90deg)}.table{width:100%;border-collapse:collapse;font-size:14px}.table th{text-align:left;font-weight:500;color:var(--md-on-surface-variant);background:rgb(var(--glass),.32);-webkit-backdrop-filter:blur(10px) saturate(140%);backdrop-filter:blur(10px) saturate(140%);font-size:12px;line-height:16px;letter-spacing:.03em;padding:8px 12px;border-bottom:1px solid var(--glass-edge);white-space:nowrap}.table td{padding:12px;border-bottom:1px solid var(--md-outline-variant);vertical-align:top}.table th:first-child,.table td:first-child{padding-left:20px}.table th:last-child,.table td:last-child{padding-right:20px}.table tbody tr:last-child td{border-bottom:0}.table tbody tr:hover td{background:var(--state-hover)}.table__actions-col{width:1%}.table__actions{white-space:nowrap}.table__actions>*{vertical-align:middle}.table__ai{margin-right:6px}@media(min-width:901px){.dashboard--fit .table thead th{position:sticky;top:0;z-index:1}}@media(prefers-reduced-transparency:reduce){.island,.table th{background:var(--md-s-low);-webkit-backdrop-filter:none;backdrop-filter:none}}.pill{display:inline-block;max-width:100%;height:24px;padding:0 10px;overflow:hidden;line-height:24px;text-overflow:ellipsis;border-radius:var(--md-r-chip);font-size:12px;font-weight:500;letter-spacing:.03em;white-space:nowrap;background:var(--md-s-high);color:var(--md-on-surface-variant)}.pill--bad{background:var(--md-error-container);color:var(--md-on-error-container)}.pill--warn{background:var(--md-warn-container);color:var(--md-on-warn-container)}.pill--ok{background:var(--md-tertiary-container);color:var(--md-on-tertiary-container)}.pill--info{background:var(--md-secondary-container);color:var(--md-on-secondary-container)}.age{display:inline-flex;align-items:center;height:18px;padding:0 6px;border-radius:6px;font-size:11px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--md-on-surface-variant);background:var(--md-s-high);cursor:help;white-space:nowrap}.age--old{color:var(--md-on-warn-container);background:var(--md-warn-container)}.handled-chip{display:inline-flex;align-items:center;height:18px;padding:0 7px;margin-left:6px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.04em;background:var(--md-s-highest);color:var(--md-on-surface-variant);vertical-align:middle}.at-you{display:inline-block;padding:1px 7px;border-radius:4px;font-size:11px;font-weight:800;letter-spacing:.03em;background:var(--md-warn-container);color:var(--md-on-warn-container)}.ticket-key{white-space:nowrap;font-variant-numeric:tabular-nums;font-weight:500}.timestamp{color:var(--md-on-surface-variant);white-space:nowrap;font-size:12px;font-variant-numeric:tabular-nums}.ai-hint{font-size:12px;opacity:.8}.hint-icon{display:inline-flex;align-items:center;justify-content:center;flex:none;box-sizing:border-box;width:16px;height:16px;padding:0;border:1px solid var(--md-outline);border-radius:50%;font-size:10px;font-weight:700;line-height:1;text-align:center;color:var(--md-on-surface-variant);cursor:pointer;-webkit-user-select:none;user-select:none}.hint-icon:hover,.hint-icon[aria-expanded=true]{color:var(--md-primary);border-color:var(--md-primary)}.hint-icon:focus-visible{outline:var(--focus-ring);outline-offset:2px}.hint-row{display:flex;align-items:center;gap:6px;min-width:0}.empty{color:var(--md-on-surface-variant);font-size:14px;margin:12px 0 0;padding-bottom:14px}.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}@media(max-width:640px){.hide-narrow{display:none}}.btn{display:inline-flex;align-items:center;height:32px;padding:0 14px;border:1px solid var(--md-outline);border-radius:999px;font-size:13px;font-weight:500;color:var(--md-primary);background:transparent;cursor:pointer;-webkit-user-select:none;user-select:none}.btn--tonal{background:var(--md-secondary-container);color:var(--md-on-secondary-container);border-color:transparent}.btn[aria-disabled=true]{opacity:.38;cursor:default;pointer-events:none}.btn:hover{box-shadow:inset 0 0 0 100px rgb(var(--md-state),.08)}.btn:focus-visible{outline:var(--focus-ring);outline-offset:2px}.btn--danger{color:var(--md-error);border-color:var(--md-error)}.input{font:inherit;font-size:13px;padding:7px 10px;border:1px solid var(--md-outline-variant);border-radius:8px;background:var(--md-s-low);color:var(--md-on-surface)}.input:focus{outline:var(--focus-ring);outline-offset:-1px;border-color:transparent}.alert{margin:0 4px 8px;padding:8px 12px;border-radius:10px;font-size:13px;font-family:var(--md-font);background:var(--md-warn-container);color:var(--md-on-warn-container)}.alert--inline{margin:0 0 8px}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:20;display:grid;place-items:center;padding:24px;background:#00000073;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}.modal__card{width:min(760px,100%);max-height:100%;display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--glass-edge);border-radius:16px;background:rgb(var(--glass),.9);-webkit-backdrop-filter:blur(24px) saturate(150%);backdrop-filter:blur(24px) saturate(150%);box-shadow:0 24px 64px #00000073}.modal__head{display:flex;align-items:center;gap:12px;padding:16px 20px;border-bottom:1px solid var(--md-outline-variant)}.modal__title{flex:1;min-width:0;margin:0;font-size:18px;font-weight:500}.modal__meta{color:var(--md-on-surface-variant);font-size:12px}.sheet-form{display:flex;flex-direction:column;gap:14px;padding:16px 20px 24px;overflow:auto;font-size:14px}.sheet-form__heading{margin:10px 0 0;font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--md-on-surface-variant)}.tippy-box[data-theme~=wt]{border:1px solid var(--tip-edge);border-radius:10px;background:var(--tip-bg);color:var(--tip-ink);box-shadow:0 12px 32px var(--tip-shadow);font:13px/1.5 Roboto,Inter,-apple-system,system-ui,sans-serif}.tippy-box[data-theme~=wt] .tippy-content{padding:10px 12px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:50vh;overflow:auto}.tippy-box[data-theme~=wt]>.tippy-arrow{color:var(--tip-bg)}
|