@worca/app 1.4.0 → 1.5.0-rc.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/README.md +36 -1
- package/docker/.env.example +54 -0
- package/docker/compose.clonein.yml +24 -0
- package/docker/compose.dev.yml +24 -0
- package/docker/compose.egress.yml +47 -0
- package/docker/compose.ssh.yml +17 -0
- package/docker/compose.teams.yml +25 -0
- package/docker/compose.yml +57 -0
- package/package.json +4 -1
- package/src/cli/container.mjs +227 -0
- package/src/cli/models.mjs +5 -4
- package/src/cli/schedule.mjs +23 -10
- package/src/cli/worca-cc.mjs +62 -4
- package/src/core/agent-user.mjs +92 -0
- package/src/core/artifacts.mjs +41 -9
- package/src/core/ask/clone-deps.mjs +25 -0
- package/src/core/ask/clone-proposal.mjs +79 -0
- package/src/core/ask/events.mjs +10 -0
- package/src/core/ask/mcp-stdio.mjs +4 -2
- package/src/core/ask/model-proposal.mjs +14 -3
- package/src/core/ask/prompt.mjs +27 -6
- package/src/core/ask/schedule-deps.mjs +16 -11
- package/src/core/ask/spawn.mjs +8 -3
- package/src/core/ask/store.mjs +40 -10
- package/src/core/ask/tool-deps.mjs +36 -1
- package/src/core/ask/tools.mjs +69 -8
- package/src/core/ask/turn.mjs +31 -1
- package/src/core/bridge/errors.mjs +50 -2
- package/src/core/bridge/provider-ops.mjs +20 -7
- package/src/core/bridge/providers/copilot.mjs +51 -5
- package/src/core/bridge/translate/common.mjs +144 -0
- package/src/core/bridge/translate/request.mjs +16 -73
- package/src/core/bridge/translate/responses-request.mjs +185 -0
- package/src/core/bridge/translate/responses-stream.mjs +327 -0
- package/src/core/bridge/upstream.mjs +37 -10
- package/src/core/cf-access.mjs +90 -0
- package/src/core/chat/command-router.mjs +17 -12
- package/src/core/claude-runner.mjs +26 -3
- package/src/core/clone-project.mjs +139 -0
- package/src/core/config.mjs +16 -9
- package/src/core/db.mjs +60 -5
- package/src/core/deployment.mjs +33 -0
- package/src/core/diff-comments.mjs +17 -8
- package/src/core/git-info.mjs +27 -7
- package/src/core/github-app.mjs +107 -0
- package/src/core/github-credentials.mjs +93 -0
- package/src/core/graph/executor.mjs +22 -4
- package/src/core/graph/script-runner.mjs +24 -3
- package/src/core/guardrails.mjs +1 -0
- package/src/core/identity.mjs +83 -0
- package/src/core/metrics/pr-events-workflow.yml +127 -0
- package/src/core/metrics/prs.mjs +383 -0
- package/src/core/metrics/read.mjs +39 -29
- package/src/core/metrics/record.mjs +30 -2
- package/src/core/metrics/sync.mjs +35 -4
- package/src/core/model-env.mjs +47 -7
- package/src/core/model-test.mjs +29 -5
- package/src/core/notifications.mjs +36 -14
- package/src/core/orchestrator.mjs +10 -4
- package/src/core/phases.mjs +2 -0
- package/src/core/pipeline-delete.mjs +4 -3
- package/src/core/plugin-repo.mjs +7 -3
- package/src/core/policy/gate.mjs +9 -5
- package/src/core/policy/state.mjs +2 -2
- package/src/core/policy/sync.mjs +5 -4
- package/src/core/remote-access.mjs +138 -0
- package/src/core/run-harness.mjs +71 -9
- package/src/core/scheduler.mjs +63 -33
- package/src/shared/team-metrics/timeline.mjs +330 -0
- package/ui/public/app.js +591 -14
- package/ui/public/ask-panel.mjs +65 -3
- package/ui/public/bridge-view.mjs +72 -14
- package/ui/public/index.html +42 -9
- package/ui/public/schedules-view.mjs +21 -2
- package/ui/public/session-guard.mjs +77 -0
- package/ui/public/style.css +171 -1
- package/ui/public/team-metrics-timeline.mjs +480 -0
- package/ui/public/team-metrics-view.mjs +10 -2
- package/ui/public/team-policy-view.mjs +6 -1
- package/ui/server.mjs +454 -104
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// src/shared/team-metrics/timeline.mjs
|
|
2
|
+
// Delivery timeline (Team metrics → Timeline). PURE: no I/O, no node: imports — the browser
|
|
3
|
+
// builds work items from the same records the Overview aggregates, joined with the PR states
|
|
4
|
+
// the server resolves (src/core/metrics/prs.mjs). A work item is every run on one ticket, or
|
|
5
|
+
// on one branch when the runs carry no ticket; "shipped" means its pull request merged.
|
|
6
|
+
|
|
7
|
+
const DAY = 86_400_000;
|
|
8
|
+
/** An open pull request older than this needs attention (someone has to review it). */
|
|
9
|
+
export const REVIEW_WAIT_DAYS = 2;
|
|
10
|
+
export const ITEM_STATUSES = Object.freeze(['shipped', 'review', 'attention', 'stopped', 'closed', 'done']);
|
|
11
|
+
const PR_RANK = { OPEN: 0, CLOSED: 1, MERGED: 2 };
|
|
12
|
+
|
|
13
|
+
const ms = (iso) => { const v = typeof iso === 'string' ? Date.parse(iso) : NaN; return Number.isFinite(v) ? v : null; };
|
|
14
|
+
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
15
|
+
|
|
16
|
+
/** Where a record's pull requests live: its project, or the workspace members it touched. */
|
|
17
|
+
export function recordRepos(r) {
|
|
18
|
+
const t = r?.target;
|
|
19
|
+
if (t?.kind === 'workspace') {
|
|
20
|
+
const list = Array.isArray(t.touched) && t.touched.length ? t.touched : Array.isArray(t.projects) ? t.projects : [];
|
|
21
|
+
return list.filter((x) => typeof x === 'string' && x);
|
|
22
|
+
}
|
|
23
|
+
return typeof t?.project === 'string' && t.project ? [t.project] : [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The project a work item is filed under: the project, or the workspace's name. */
|
|
27
|
+
export function recordProjectLabel(r) {
|
|
28
|
+
const t = r?.target;
|
|
29
|
+
if (t?.kind === 'workspace') return t.workspace || 'Workspace';
|
|
30
|
+
return t?.project || 'Unknown project';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Runs on the same ticket form one work item; without a ticket, runs on the same branch do
|
|
35
|
+
* (a resume or a follow-up on the branch); otherwise the run stands alone.
|
|
36
|
+
*/
|
|
37
|
+
export function workItemKey(r) {
|
|
38
|
+
const src = r?.source;
|
|
39
|
+
if (src && (src.url || src.ref)) return src.url ? `src:${src.url}` : `src:${recordProjectLabel(r)}:${src.type || ''}:${src.ref}`;
|
|
40
|
+
const branch = r?.git?.branch;
|
|
41
|
+
if (typeof branch === 'string' && branch) return `br:${recordRepos(r).join(',').toLowerCase()}:${branch}`;
|
|
42
|
+
return `run:${r?.id}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The lookups the server needs to resolve a record's pull requests. */
|
|
46
|
+
export function prLookupFor(r) {
|
|
47
|
+
const pr = r?.pr && (r.pr.url || Number.isInteger(r.pr.number)) ? { url: typeof r.pr.url === 'string' ? r.pr.url : null, number: Number.isInteger(r.pr.number) ? r.pr.number : null } : null;
|
|
48
|
+
return { id: r.id, repos: recordRepos(r), branch: typeof r?.git?.branch === 'string' && r.git.branch ? r.git.branch : null, pr, endedAt: typeof r?.endedAt === 'string' ? r.endedAt : null };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normPr(p) {
|
|
52
|
+
if (!p || typeof p !== 'object') return null;
|
|
53
|
+
const state = typeof p.state === 'string' ? p.state.toUpperCase() : null;
|
|
54
|
+
if (!(state in PR_RANK)) return null;
|
|
55
|
+
return {
|
|
56
|
+
repo: typeof p.repo === 'string' ? p.repo : null,
|
|
57
|
+
number: Number.isInteger(p.number) ? p.number : null,
|
|
58
|
+
url: typeof p.url === 'string' ? p.url : null,
|
|
59
|
+
title: typeof p.title === 'string' ? p.title : null,
|
|
60
|
+
state,
|
|
61
|
+
createdAt: ms(p.createdAt),
|
|
62
|
+
mergedAt: ms(p.mergedAt),
|
|
63
|
+
closedAt: ms(p.closedAt),
|
|
64
|
+
via: typeof p.via === 'string' ? p.via : null,
|
|
65
|
+
author: typeof p.author === 'string' && p.author ? p.author : null,
|
|
66
|
+
authorName: typeof p.authorName === 'string' && p.authorName ? p.authorName : null,
|
|
67
|
+
authorKey: typeof p.authorKey === 'string' && p.authorKey ? p.authorKey : null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* People across runs and pull requests. A person is keyed by `actorKey` (a hash of the git email,
|
|
73
|
+
* the same on every host and machine); keys that carry the same git name (case and spacing aside)
|
|
74
|
+
* are one person with several emails. An item without a key joins the person whose name or GitHub
|
|
75
|
+
* login it carries, else stands under its name. The label is the name seen most often.
|
|
76
|
+
* @param {object[]} items the items to group (e.g. the ones on screen).
|
|
77
|
+
* @param {object} [opts]
|
|
78
|
+
* @param {object[]} [opts.known] items to learn name/login → key from (the whole scope), so an
|
|
79
|
+
* item on screen joins its person even when the item that carries the key is off screen.
|
|
80
|
+
* @returns {{ key: string, label: string, items: object[] }[]} sorted by label, "Unattributed" last.
|
|
81
|
+
*/
|
|
82
|
+
export function groupByPerson(items, { known = items } = {}) {
|
|
83
|
+
const low = (s) => String(s || '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
84
|
+
// Union-find over person keys and git names: a key and the git name it carries are one person,
|
|
85
|
+
// so two keys (two emails) with the same name join. Logins never join keys: a PR's login is who
|
|
86
|
+
// opened it, which need not be who wrote its commits.
|
|
87
|
+
const parent = new Map();
|
|
88
|
+
const find = (x) => {
|
|
89
|
+
if (!parent.has(x)) parent.set(x, x);
|
|
90
|
+
let r = x;
|
|
91
|
+
while (parent.get(r) !== r) r = parent.get(r);
|
|
92
|
+
while (parent.get(x) !== r) { const next = parent.get(x); parent.set(x, r); x = next; }
|
|
93
|
+
return r;
|
|
94
|
+
};
|
|
95
|
+
const union = (a, b) => { const ra = find(a); const rb = find(b); if (ra !== rb) parent.set(ra < rb ? rb : ra, ra < rb ? ra : rb); };
|
|
96
|
+
const loginKey = new Map(); // lower(login) → key, for key-less items only
|
|
97
|
+
for (const it of known) {
|
|
98
|
+
if (!it.actorKey) continue;
|
|
99
|
+
const gitName = it.actor && it.actor !== it.login ? low(it.actor) : null;
|
|
100
|
+
if (gitName) union(`k:${it.actorKey}`, `n:${gitName}`);
|
|
101
|
+
else find(`k:${it.actorKey}`);
|
|
102
|
+
if (it.login && !loginKey.has(low(it.login))) loginKey.set(low(it.login), it.actorKey);
|
|
103
|
+
}
|
|
104
|
+
const groups = new Map();
|
|
105
|
+
for (const it of items) {
|
|
106
|
+
let key = '';
|
|
107
|
+
if (it.actorKey) key = find(`k:${it.actorKey}`);
|
|
108
|
+
else if (it.actor && parent.has(`n:${low(it.actor)}`)) key = find(`n:${low(it.actor)}`);
|
|
109
|
+
else if (it.login && loginKey.has(low(it.login))) key = find(`k:${loginKey.get(low(it.login))}`);
|
|
110
|
+
else if (it.actor) key = `n:${low(it.actor)}`;
|
|
111
|
+
if (!groups.has(key)) groups.set(key, { key, names: new Map(), items: [] });
|
|
112
|
+
const g = groups.get(key);
|
|
113
|
+
g.items.push(it);
|
|
114
|
+
const shown = it.actor ? String(it.actor).trim().replace(/\s+/g, ' ') : '';
|
|
115
|
+
if (shown) g.names.set(shown, (g.names.get(shown) || 0) + (it.login && it.actor === it.login ? 0.5 : 1));
|
|
116
|
+
}
|
|
117
|
+
// Most frequent spelling; on a tie, a capitalised one ("Denislav Prinov" over "denislav prinov").
|
|
118
|
+
const allLower = (s) => (s === s.toLowerCase() ? 1 : 0);
|
|
119
|
+
const out = [...groups.values()].map((g) => ({
|
|
120
|
+
key: g.key,
|
|
121
|
+
label: [...g.names.entries()].sort((a, b) => b[1] - a[1] || allLower(a[0]) - allLower(b[0]) || a[0].localeCompare(b[0]))[0]?.[0] || 'Unattributed',
|
|
122
|
+
items: g.items,
|
|
123
|
+
}));
|
|
124
|
+
return out.sort((a, b) => (a.key === '' ? 1 : b.key === '' ? -1 : a.label.localeCompare(b.label)));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const RUN_RESULTS = new Set(['done', 'failed', 'stopped']);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {object[]} records RunRecord v1 rows (already scoped).
|
|
131
|
+
* @param {object} [opts]
|
|
132
|
+
* @param {Object<string, object[]|null>} [opts.prs] run id → its pull requests; `[]` = looked up,
|
|
133
|
+
* none; null/absent = unknown (not looked up, or no way to look it up).
|
|
134
|
+
* @param {object[]} [opts.outside] PR events of the scope (GET /api/team-metrics/pr-events); the
|
|
135
|
+
* ones no run points at become work items of their own (`kind: 'pr'`, no runs).
|
|
136
|
+
* @param {number} [opts.now]
|
|
137
|
+
*/
|
|
138
|
+
export function buildWorkItems(records, { prs = {}, outside = [], now = Date.now() } = {}) {
|
|
139
|
+
const groups = new Map();
|
|
140
|
+
for (const r of records || []) {
|
|
141
|
+
const s = ms(r?.startedAt);
|
|
142
|
+
if (s == null) continue;
|
|
143
|
+
const key = workItemKey(r);
|
|
144
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
145
|
+
groups.get(key).push(r);
|
|
146
|
+
}
|
|
147
|
+
const items = [];
|
|
148
|
+
for (const [key, rs] of groups) {
|
|
149
|
+
rs.sort((a, b) => ms(a.startedAt) - ms(b.startedAt));
|
|
150
|
+
const runs = rs.map((r) => {
|
|
151
|
+
const s = ms(r.startedAt);
|
|
152
|
+
const e = Math.max(s, ms(r.endedAt) ?? (isNum(r.wallMs) ? s + r.wallMs : s));
|
|
153
|
+
return {
|
|
154
|
+
id: r.id, s, e,
|
|
155
|
+
result: RUN_RESULTS.has(r.result) ? r.result : 'done',
|
|
156
|
+
costUsd: isNum(r?.cost?.usd) ? r.cost.usd : 0,
|
|
157
|
+
activeMs: isNum(r.activeMs) ? r.activeMs : e - s,
|
|
158
|
+
actor: r.actor || null,
|
|
159
|
+
actorKey: typeof r.actorKey === 'string' && r.actorKey ? r.actorKey : null,
|
|
160
|
+
reviewCycles: isNum(r?.cycles?.review) ? r.cycles.review : 0,
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
const last = rs[rs.length - 1];
|
|
164
|
+
const lastRun = runs[runs.length - 1];
|
|
165
|
+
// Pull requests: one per repo#number across the runs, keeping the most final state seen.
|
|
166
|
+
let prKnown = false;
|
|
167
|
+
const byKey = new Map();
|
|
168
|
+
for (const r of rs) {
|
|
169
|
+
const list = prs?.[r.id];
|
|
170
|
+
if (!Array.isArray(list)) continue;
|
|
171
|
+
prKnown = true;
|
|
172
|
+
for (const raw of list) {
|
|
173
|
+
const p = normPr(raw);
|
|
174
|
+
if (!p) continue;
|
|
175
|
+
const k = `${(p.repo || '').toLowerCase()}#${p.number ?? p.url}`;
|
|
176
|
+
const old = byKey.get(k);
|
|
177
|
+
if (!old || PR_RANK[p.state] > PR_RANK[old.state] || (PR_RANK[p.state] === PR_RANK[old.state] && (p.mergedAt ?? p.closedAt ?? 0) > (old.mergedAt ?? old.closedAt ?? 0))) byKey.set(k, p);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const pulls = [...byKey.values()].sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
|
|
181
|
+
const actorsCount = new Map();
|
|
182
|
+
for (const run of runs) if (run.actor) actorsCount.set(run.actor, (actorsCount.get(run.actor) || 0) + 1);
|
|
183
|
+
const actors = [...actorsCount.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([a]) => a);
|
|
184
|
+
const src = [...rs].reverse().find((r) => r.source)?.source || null;
|
|
185
|
+
const item = {
|
|
186
|
+
key,
|
|
187
|
+
title: src?.title || last.title || src?.ref || 'Untitled run',
|
|
188
|
+
project: recordProjectLabel(last),
|
|
189
|
+
ticket: src ? { type: src.type || null, ref: src.ref || null, url: src.url || null, title: src.title || null } : null,
|
|
190
|
+
branch: last?.git?.branch || null,
|
|
191
|
+
actor: actors[0] || null,
|
|
192
|
+
// The primary actor's key (their runs first); older records carry none.
|
|
193
|
+
actorKey: (runs.find((x) => x.actor === actors[0] && x.actorKey) || runs.find((x) => x.actorKey))?.actorKey || null,
|
|
194
|
+
login: null,
|
|
195
|
+
actors,
|
|
196
|
+
runs,
|
|
197
|
+
first: runs[0].s,
|
|
198
|
+
lastEnd: Math.max(...runs.map((x) => x.e)),
|
|
199
|
+
costUsd: Math.round(runs.reduce((a, x) => a + x.costUsd, 0) * 100) / 100,
|
|
200
|
+
activeMs: runs.reduce((a, x) => a + x.activeMs, 0),
|
|
201
|
+
reviewCycles: Math.max(0, ...runs.map((x) => x.reviewCycles)),
|
|
202
|
+
failed: runs.filter((x) => x.result === 'failed').length,
|
|
203
|
+
prs: pulls,
|
|
204
|
+
prKnown,
|
|
205
|
+
prOpenAt: null, mergedAt: null, closedAt: null,
|
|
206
|
+
status: 'done', reason: null, end: null,
|
|
207
|
+
};
|
|
208
|
+
if (!applyPrStatus(item, pulls, now)) {
|
|
209
|
+
item.end = item.lastEnd;
|
|
210
|
+
if (lastRun.result === 'failed') {
|
|
211
|
+
item.status = 'attention';
|
|
212
|
+
if (item.failed > 1 && item.failed === runs.length) item.reason = `All ${item.failed} attempts failed.`;
|
|
213
|
+
else if (item.failed > 1) item.reason = `The last attempt failed (${item.failed} failed in total).`;
|
|
214
|
+
else item.reason = 'The last attempt failed.';
|
|
215
|
+
} else if (lastRun.result === 'stopped') item.status = 'stopped';
|
|
216
|
+
else item.status = 'done';
|
|
217
|
+
}
|
|
218
|
+
items.push(item);
|
|
219
|
+
}
|
|
220
|
+
// Pull requests no recorded run points at (by number, or by the run's branch): work done
|
|
221
|
+
// outside Worca, shown so the Timeline covers the whole team's delivery.
|
|
222
|
+
const lower = (s) => String(s || '').toLowerCase();
|
|
223
|
+
const runBranches = new Set();
|
|
224
|
+
for (const r of records || []) {
|
|
225
|
+
const b = r?.git?.branch;
|
|
226
|
+
if (typeof b === 'string' && b) for (const repo of recordRepos(r)) runBranches.add(`${lower(repo)}#${b}`);
|
|
227
|
+
}
|
|
228
|
+
const attached = new Set(items.flatMap((it) => it.prs.map((p) => `${lower(p.repo)}#${p.number}`)));
|
|
229
|
+
for (const ev of outside || []) {
|
|
230
|
+
const p = normPr({ ...ev, via: ev?.via || 'action' });
|
|
231
|
+
if (!p || p.number == null || p.createdAt == null) continue;
|
|
232
|
+
const k = `${lower(p.repo)}#${p.number}`;
|
|
233
|
+
if (attached.has(k) || runBranches.has(`${lower(p.repo)}#${ev.head}`)) continue;
|
|
234
|
+
attached.add(k);
|
|
235
|
+
const item = {
|
|
236
|
+
key: `pr:${k}`,
|
|
237
|
+
kind: 'pr',
|
|
238
|
+
title: p.title || `Pull request #${p.number}`,
|
|
239
|
+
project: lower(p.repo),
|
|
240
|
+
ticket: null,
|
|
241
|
+
branch: typeof ev.head === 'string' ? ev.head : null,
|
|
242
|
+
// The git author of most of its commits (as for runs), else the host login.
|
|
243
|
+
actor: p.authorName || p.author,
|
|
244
|
+
actorKey: p.authorKey,
|
|
245
|
+
login: p.author,
|
|
246
|
+
actors: p.authorName || p.author ? [p.authorName || p.author] : [],
|
|
247
|
+
runs: [],
|
|
248
|
+
first: p.createdAt,
|
|
249
|
+
lastEnd: p.createdAt,
|
|
250
|
+
costUsd: 0, activeMs: 0, reviewCycles: 0, failed: 0,
|
|
251
|
+
prs: [p],
|
|
252
|
+
prKnown: true,
|
|
253
|
+
prOpenAt: null, mergedAt: null, closedAt: null,
|
|
254
|
+
status: 'done', reason: null, end: null,
|
|
255
|
+
};
|
|
256
|
+
applyPrStatus(item, [p], now);
|
|
257
|
+
items.push(item);
|
|
258
|
+
}
|
|
259
|
+
return items.sort((a, b) => a.first - b.first);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Status from the item's pull requests; false when it has none (the runs decide then). */
|
|
263
|
+
function applyPrStatus(item, pulls, now) {
|
|
264
|
+
const open = pulls.filter((p) => p.state === 'OPEN');
|
|
265
|
+
const merged = pulls.filter((p) => p.state === 'MERGED');
|
|
266
|
+
const closed = pulls.filter((p) => p.state === 'CLOSED');
|
|
267
|
+
const opens = pulls.map((p) => p.createdAt).filter(isNum);
|
|
268
|
+
item.prOpenAt = opens.length ? Math.min(...opens) : null;
|
|
269
|
+
if (open.length) {
|
|
270
|
+
item.end = Math.max(now, item.lastEnd);
|
|
271
|
+
const since = Math.min(...open.map((p) => p.createdAt ?? item.lastEnd));
|
|
272
|
+
const waitedDays = (now - since) / DAY;
|
|
273
|
+
if (waitedDays > REVIEW_WAIT_DAYS) {
|
|
274
|
+
const d = Math.floor(waitedDays);
|
|
275
|
+
item.status = 'attention';
|
|
276
|
+
item.reason = `Pull request waiting for review for ${d} day${d === 1 ? '' : 's'}.`;
|
|
277
|
+
} else item.status = 'review';
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
if (merged.length) {
|
|
281
|
+
const at = merged.map((p) => p.mergedAt).filter(isNum);
|
|
282
|
+
item.mergedAt = at.length ? Math.max(...at) : null;
|
|
283
|
+
item.status = 'shipped';
|
|
284
|
+
item.end = Math.max(item.mergedAt ?? item.lastEnd, item.lastEnd);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
if (closed.length) {
|
|
288
|
+
const at = closed.map((p) => p.closedAt).filter(isNum);
|
|
289
|
+
item.closedAt = at.length ? Math.max(...at) : null;
|
|
290
|
+
item.status = 'closed';
|
|
291
|
+
item.end = Math.max(item.closedAt ?? item.lastEnd, item.lastEnd);
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function median(values) {
|
|
298
|
+
const xs = values.filter(isNum).sort((a, b) => a - b);
|
|
299
|
+
if (!xs.length) return null;
|
|
300
|
+
const m = xs.length >> 1;
|
|
301
|
+
return xs.length % 2 ? xs[m] : (xs[m - 1] + xs[m]) / 2;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The summary strip for one calendar window [startMs, endMs).
|
|
306
|
+
* - shipped: merged inside the window; completed: finished `done` inside the window (the
|
|
307
|
+
* fallback when no merge data is available at all);
|
|
308
|
+
* - inReview: a pull request open at the window's end (or now, for the current window);
|
|
309
|
+
* - attention: visible items that need someone now.
|
|
310
|
+
*/
|
|
311
|
+
export function summarizeWindow(items, { startMs, endMs, now = Date.now() }) {
|
|
312
|
+
const inWin = (t) => isNum(t) && t >= startMs && t < endMs;
|
|
313
|
+
const visible = items.filter((it) => it.first < endMs && it.end >= startMs);
|
|
314
|
+
const shipped = items.filter((it) => it.status === 'shipped' && inWin(it.mergedAt ?? it.lastEnd));
|
|
315
|
+
const cut = Math.min(endMs, now);
|
|
316
|
+
// A PR is "in review" from its creation until it merges or closes; one that merged or closed on
|
|
317
|
+
// an unknown date is never counted (it would otherwise stay in review forever).
|
|
318
|
+
const openUntil = (it) => (it.prs.some((p) => p.state === 'OPEN') ? Infinity : it.mergedAt ?? it.closedAt);
|
|
319
|
+
const inReview = cut < startMs ? [] : items.filter((it) => it.prOpenAt != null && it.prOpenAt <= cut && openUntil(it) != null && openUntil(it) > cut);
|
|
320
|
+
const attention = visible.filter((it) => it.status === 'attention');
|
|
321
|
+
const completed = items.filter((it) => it.runs.length && it.runs[it.runs.length - 1].result === 'done' && inWin(it.lastEnd));
|
|
322
|
+
const leads = shipped.filter((it) => it.mergedAt != null).map((it) => it.mergedAt - it.first);
|
|
323
|
+
const spendUsd = Math.round(items.reduce((a, it) => a + it.runs.filter((r) => inWin(r.s)).reduce((b, r) => b + r.costUsd, 0), 0) * 100) / 100;
|
|
324
|
+
return {
|
|
325
|
+
visible, shipped, inReview, attention, completed,
|
|
326
|
+
medianLeadMs: median(leads),
|
|
327
|
+
spendUsd,
|
|
328
|
+
prKnown: items.some((it) => it.prKnown),
|
|
329
|
+
};
|
|
330
|
+
}
|