@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,383 @@
|
|
|
1
|
+
// src/core/metrics/prs.mjs
|
|
2
|
+
// Pull-request states for the delivery timeline (docs/team-metrics.md "Timeline"). A run record
|
|
3
|
+
// is written when the run ends, before its PR exists, so the merge is joined in at read time from
|
|
4
|
+
// three sources, most trusted first:
|
|
5
|
+
// 1. PR event files on the worca-metrics branch (`.worca-metrics/prs/<number>.json`), written
|
|
6
|
+
// by the optional GitHub Action (docs/team-metrics-pr-events.md);
|
|
7
|
+
// 2. the GitHub CLI: one batched GraphQL query per 30 branches, cached in
|
|
8
|
+
// ~/.worca-cc/metrics/pr-cache.json (a merged PR is never asked about again);
|
|
9
|
+
// 3. this machine's own pipelines table (pr_url / pr_state, no dates).
|
|
10
|
+
// Every source is optional. Without gh and without the Action the page still renders; the
|
|
11
|
+
// status block says which source was missing so the page can explain the gap. Never throws.
|
|
12
|
+
import { execFile } from 'node:child_process';
|
|
13
|
+
import { lstat, readdir, readFile, realpath, mkdir, writeFile, rename } from 'node:fs/promises';
|
|
14
|
+
import { join, sep } from 'node:path';
|
|
15
|
+
import { METRICS_DIR, metricsRoot, worktreePath } from './sync.mjs';
|
|
16
|
+
import { prepare } from '../db.mjs';
|
|
17
|
+
|
|
18
|
+
export const PR_EVENTS_DIR = 'prs';
|
|
19
|
+
export const MAX_EVENT_FILE_BYTES = 64 * 1024;
|
|
20
|
+
export const GH_BATCH = 30;
|
|
21
|
+
export const MAX_LOOKUPS = 600; // per request: the page asks for the window it shows
|
|
22
|
+
export const OPEN_TTL_MS = 10 * 60_000; // an open PR (or none yet) is re-asked after this
|
|
23
|
+
export const NONE_OLD_TTL_MS = 24 * 3_600_000; // …or after a day, once the run is a month old
|
|
24
|
+
export const GH_STATUS_TTL_MS = 5 * 60_000;
|
|
25
|
+
const DAY = 86_400_000;
|
|
26
|
+
const STATES = new Set(['OPEN', 'MERGED', 'CLOSED']);
|
|
27
|
+
|
|
28
|
+
function defaultRun(cmd, args, { timeout = 30_000 } = {}) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
try {
|
|
31
|
+
execFile(cmd, args, { timeout, maxBuffer: 16 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
|
|
32
|
+
resolve({ ok: !err, stdout: String(stdout || ''), stderr: String(stderr || err?.message || ''), code: err ? (typeof err.code === 'number' ? err.code : -1) : 0, missing: err?.code === 'ENOENT' });
|
|
33
|
+
});
|
|
34
|
+
} catch (err) { resolve({ ok: false, stdout: '', stderr: String(err?.message || err), code: -1, missing: err?.code === 'ENOENT' }); }
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let _run = defaultRun;
|
|
39
|
+
let _now = () => Date.now();
|
|
40
|
+
let _ghStatus = null; // { state, detail, at }
|
|
41
|
+
|
|
42
|
+
const isoOrNull = (v) => (typeof v === 'string' && Number.isFinite(Date.parse(v)) ? v : null);
|
|
43
|
+
const lower = (s) => String(s || '').toLowerCase();
|
|
44
|
+
/** github.com slugs are exactly "owner/repo"; every other host keeps its host segment. */
|
|
45
|
+
export const isGithubSlug = (slug) => typeof slug === 'string' && /^[a-z0-9._-]+\/[a-z0-9._-]+$/i.test(slug);
|
|
46
|
+
|
|
47
|
+
/** "https://github.com/o/r/pull/12" → { repo: 'o/r', number: 12 } | null */
|
|
48
|
+
export function parsePrUrl(url) {
|
|
49
|
+
const m = /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/i.exec(String(url || ''));
|
|
50
|
+
return m ? { repo: `${m[1]}/${m[2]}`, number: Number(m[3]) } : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One PR event file (v1) → a normalised PR, or null when it is not one. */
|
|
54
|
+
export function parsePrEvent(text) {
|
|
55
|
+
let v;
|
|
56
|
+
try { v = JSON.parse(text); } catch { return null; }
|
|
57
|
+
if (!v || typeof v !== 'object' || v.v !== 1 || v.kind !== 'pr') return null;
|
|
58
|
+
if (typeof v.repo !== 'string' || !Number.isInteger(v.number) || typeof v.head !== 'string') return null;
|
|
59
|
+
const state = String(v.state || '').toUpperCase();
|
|
60
|
+
if (!STATES.has(state)) return null;
|
|
61
|
+
return {
|
|
62
|
+
repo: v.repo, number: v.number, url: typeof v.url === 'string' ? v.url : null,
|
|
63
|
+
title: typeof v.title === 'string' ? v.title.slice(0, 200) : null,
|
|
64
|
+
head: v.head, base: typeof v.base === 'string' ? v.base : null, state,
|
|
65
|
+
author: typeof v.author === 'string' && v.author ? v.author.slice(0, 100) : null,
|
|
66
|
+
authorName: typeof v.authorName === 'string' && v.authorName ? v.authorName.slice(0, 200) : null,
|
|
67
|
+
authorKey: typeof v.authorKey === 'string' && /^[0-9a-f]{16}$/.test(v.authorKey) ? v.authorKey : null,
|
|
68
|
+
createdAt: isoOrNull(v.createdAt), mergedAt: isoOrNull(v.mergedAt), closedAt: isoOrNull(v.closedAt),
|
|
69
|
+
updatedAt: isoOrNull(v.updatedAt), via: 'action',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every PR event under a metrics worktree. Same guards as the run records (read.mjs). */
|
|
74
|
+
export async function readPrEventsFromDir(dir) {
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const p of [join(dir, METRICS_DIR), join(dir, METRICS_DIR, PR_EVENTS_DIR)]) {
|
|
77
|
+
const st = await lstat(p).catch(() => null);
|
|
78
|
+
if (!st || !st.isDirectory()) return out;
|
|
79
|
+
}
|
|
80
|
+
const root = join(dir, METRICS_DIR, PR_EVENTS_DIR);
|
|
81
|
+
const rootReal = await realpath(root).catch(() => null);
|
|
82
|
+
if (!rootReal) return out;
|
|
83
|
+
let ents;
|
|
84
|
+
try { ents = await readdir(root, { withFileTypes: true }); } catch { return out; }
|
|
85
|
+
for (const d of ents) {
|
|
86
|
+
if (!d.isFile() || !/^\d+\.json$/.test(d.name)) continue;
|
|
87
|
+
const p = join(root, d.name);
|
|
88
|
+
const st = await lstat(p).catch(() => null);
|
|
89
|
+
const real = st ? await realpath(p).catch(() => null) : null;
|
|
90
|
+
if (!st || !st.isFile() || st.size > MAX_EVENT_FILE_BYTES || !real || !real.startsWith(rootReal + sep)) continue;
|
|
91
|
+
const ev = parsePrEvent(await readFile(p, 'utf8').catch(() => ''));
|
|
92
|
+
if (ev) out.push(ev);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- gh ------------------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/** 'ok' | 'missing' (no gh on PATH) | 'unauthenticated'. Memoised for a few minutes. */
|
|
100
|
+
export async function ghStatus() {
|
|
101
|
+
const now = _now();
|
|
102
|
+
if (_ghStatus && now - _ghStatus.at < GH_STATUS_TTL_MS) return _ghStatus;
|
|
103
|
+
const r = await _run('gh', ['auth', 'status', '--hostname', 'github.com'], { timeout: 15_000 });
|
|
104
|
+
const state = r.ok ? 'ok' : r.missing ? 'missing' : 'unauthenticated';
|
|
105
|
+
_ghStatus = { state, detail: r.ok ? null : String(r.stderr || '').trim().split('\n')[0] || null, at: now };
|
|
106
|
+
return _ghStatus;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const gqlString = (s) => JSON.stringify(String(s));
|
|
110
|
+
const PR_FIELDS = 'number url title state createdAt mergedAt closedAt headRefName baseRefName';
|
|
111
|
+
|
|
112
|
+
/** One GraphQL document for up to GH_BATCH {repo, branch} lookups, aliased q0…qN. */
|
|
113
|
+
export function buildBranchQuery(lookups) {
|
|
114
|
+
const parts = lookups.map((l, i) => {
|
|
115
|
+
const [owner, name] = l.repo.split('/');
|
|
116
|
+
return `q${i}: repository(owner: ${gqlString(owner)}, name: ${gqlString(name)}) { pullRequests(headRefName: ${gqlString(l.branch)}, first: 5, orderBy: {field: CREATED_AT, direction: DESC}) { nodes { ${PR_FIELDS} } } }`;
|
|
117
|
+
});
|
|
118
|
+
return `query {\n${parts.join('\n')}\n}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function fromGhNode(repo, n) {
|
|
122
|
+
if (!n || !Number.isInteger(n.number) || !STATES.has(n.state)) return null;
|
|
123
|
+
return { repo, number: n.number, url: n.url || null, title: n.title || null, head: n.headRefName || null, base: n.baseRefName || null, state: n.state, createdAt: isoOrNull(n.createdAt), mergedAt: isoOrNull(n.mergedAt), closedAt: isoOrNull(n.closedAt), via: 'gh' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @returns {Promise<{results: Map<string, object[]>, error: string|null}>} key "repo#branch"
|
|
128
|
+
* (lower-cased repo) → PRs. A lookup GitHub could not answer (no access, unknown repo) is
|
|
129
|
+
* left out, so it is asked again next time rather than cached as "no PR".
|
|
130
|
+
*/
|
|
131
|
+
export async function lookupBranchesViaGh(lookups) {
|
|
132
|
+
const results = new Map();
|
|
133
|
+
let error = null;
|
|
134
|
+
for (let i = 0; i < lookups.length; i += GH_BATCH) {
|
|
135
|
+
const batch = lookups.slice(i, i + GH_BATCH);
|
|
136
|
+
const r = await _run('gh', ['api', 'graphql', '-f', `query=${buildBranchQuery(batch)}`], { timeout: 30_000 });
|
|
137
|
+
let body = null;
|
|
138
|
+
try { body = JSON.parse(r.stdout || 'null'); } catch { body = null; }
|
|
139
|
+
// gh exits non-zero when ANY alias errors (an unknown repo), but still prints the partial data.
|
|
140
|
+
if (!body || typeof body !== 'object' || !body.data) { error ||= String(r.stderr || 'gh api graphql failed').trim().split('\n')[0]; continue; }
|
|
141
|
+
batch.forEach((l, j) => {
|
|
142
|
+
const repoNode = body.data[`q${j}`];
|
|
143
|
+
if (!repoNode) return;
|
|
144
|
+
const nodes = Array.isArray(repoNode.pullRequests?.nodes) ? repoNode.pullRequests.nodes : [];
|
|
145
|
+
results.set(`${lower(l.repo)}#${l.branch}`, nodes.map((n) => fromGhNode(l.repo, n)).filter(Boolean));
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return { results, error };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- cache ---------------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
export function cachePath() { return join(metricsRoot(), 'pr-cache.json'); }
|
|
154
|
+
|
|
155
|
+
async function loadCache() {
|
|
156
|
+
try {
|
|
157
|
+
const v = JSON.parse(await readFile(cachePath(), 'utf8'));
|
|
158
|
+
return v && v.v === 1 && v.entries && typeof v.entries === 'object' ? v : { v: 1, entries: {} };
|
|
159
|
+
} catch { return { v: 1, entries: {} }; }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function saveCache(cache) {
|
|
163
|
+
try {
|
|
164
|
+
await mkdir(metricsRoot(), { recursive: true });
|
|
165
|
+
const tmp = `${cachePath()}.${process.pid}.tmp`;
|
|
166
|
+
await writeFile(tmp, JSON.stringify(cache));
|
|
167
|
+
await rename(tmp, cachePath());
|
|
168
|
+
} catch { /* a cache that cannot be written is only slower next time */ }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A cached answer is final once a PR merged; otherwise it is re-asked after a TTL. */
|
|
172
|
+
export function cacheFresh(entry, { now = _now(), runEndedMs = null } = {}) {
|
|
173
|
+
if (!entry || !Array.isArray(entry.prs) || !Number.isFinite(entry.checkedAt)) return false;
|
|
174
|
+
if (entry.prs.some((p) => p.state === 'MERGED')) return true;
|
|
175
|
+
const old = runEndedMs != null && now - runEndedMs > 30 * DAY;
|
|
176
|
+
const ttl = entry.prs.length === 0 && old ? NONE_OLD_TTL_MS : entry.prs.every((p) => p.state === 'CLOSED') && entry.prs.length ? NONE_OLD_TTL_MS : OPEN_TTL_MS;
|
|
177
|
+
return now - entry.checkedAt < ttl;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---- local pipelines -----------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
function localPrs(ids) {
|
|
183
|
+
const out = new Map();
|
|
184
|
+
if (!ids.length) return out;
|
|
185
|
+
try {
|
|
186
|
+
for (let i = 0; i < ids.length; i += 500) {
|
|
187
|
+
const chunk = ids.slice(i, i + 500);
|
|
188
|
+
const rows = prepare(`SELECT id, pr_url, pr_number, pr_state FROM pipelines WHERE pr_url IS NOT NULL AND id IN (${chunk.map(() => '?').join(',')})`).all(...chunk);
|
|
189
|
+
for (const row of rows) {
|
|
190
|
+
const parsed = parsePrUrl(row.pr_url);
|
|
191
|
+
const state = STATES.has(String(row.pr_state || '').toUpperCase()) ? String(row.pr_state).toUpperCase() : 'OPEN';
|
|
192
|
+
out.set(row.id, { repo: parsed?.repo || null, number: row.pr_number ?? parsed?.number ?? null, url: row.pr_url, title: null, state, createdAt: null, mergedAt: null, closedAt: null, via: 'local' });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch { /* no DB (a CLI-less reader): skip */ }
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---- resolve -------------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
const RANK = { OPEN: 0, CLOSED: 1, MERGED: 2 };
|
|
202
|
+
const VIA_RANK = { local: 1, gh: 2, action: 3 };
|
|
203
|
+
/** The better of two answers for one PR: a more final state, then a more trusted source. */
|
|
204
|
+
function better(a, b) {
|
|
205
|
+
if (!a) return b;
|
|
206
|
+
if (RANK[b.state] !== RANK[a.state]) return RANK[b.state] > RANK[a.state] ? b : a;
|
|
207
|
+
return (VIA_RANK[b.via] ?? 0) >= (VIA_RANK[a.via] ?? 0) ? { ...a, ...Object.fromEntries(Object.entries(b).filter(([, v]) => v != null)) } : a;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sanitizeLookup(x) {
|
|
211
|
+
if (!x || typeof x.id !== 'string' || !x.id || x.id.length > 64) return null;
|
|
212
|
+
const repos = Array.isArray(x.repos) ? x.repos.filter((r) => typeof r === 'string' && r && r.length <= 200).slice(0, 20) : [];
|
|
213
|
+
const branch = typeof x.branch === 'string' && x.branch && x.branch.length <= 255 ? x.branch : null;
|
|
214
|
+
const pr = x.pr && typeof x.pr === 'object' ? { url: typeof x.pr.url === 'string' ? x.pr.url : null, number: Number.isInteger(x.pr.number) ? x.pr.number : null } : null;
|
|
215
|
+
const endedMs = typeof x.endedAt === 'string' ? Date.parse(x.endedAt) : NaN;
|
|
216
|
+
return { id: x.id, repos, branch, pr, endedMs: Number.isFinite(endedMs) ? endedMs : null };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {object} p
|
|
221
|
+
* @param {object[]} p.runs [{ id, repos, branch, pr, endedAt }] from prLookupFor() (shared/timeline).
|
|
222
|
+
* @param {string[]} p.sinks metrics slugs whose worktrees may hold PR events.
|
|
223
|
+
* @param {boolean} [p.useGh]
|
|
224
|
+
* @returns {Promise<{ prs: Object<string, object[]|null>, status: object }>}
|
|
225
|
+
*/
|
|
226
|
+
export async function resolveRunPrs({ runs = [], sinks = [], useGh = true } = {}) {
|
|
227
|
+
const now = _now();
|
|
228
|
+
const lookups = runs.map(sanitizeLookup).filter(Boolean).slice(0, MAX_LOOKUPS);
|
|
229
|
+
const status = { gh: 'unused', ghDetail: null, ghError: null, actionRepos: [], unsupportedRepos: [], checked: 0 };
|
|
230
|
+
|
|
231
|
+
// 1. Action events, indexed by repo#branch and repo#number.
|
|
232
|
+
const byBranch = new Map();
|
|
233
|
+
const byNumber = new Map();
|
|
234
|
+
const actionRepos = new Set();
|
|
235
|
+
for (const slug of sinks) {
|
|
236
|
+
let dir;
|
|
237
|
+
try { dir = worktreePath(slug); } catch { continue; }
|
|
238
|
+
for (const ev of await readPrEventsFromDir(dir)) {
|
|
239
|
+
const repo = lower(ev.repo);
|
|
240
|
+
actionRepos.add(repo);
|
|
241
|
+
const bk = `${repo}#${ev.head}`;
|
|
242
|
+
if (!byBranch.has(bk)) byBranch.set(bk, []);
|
|
243
|
+
byBranch.get(bk).push(ev);
|
|
244
|
+
byNumber.set(`${repo}#${ev.number}`, ev);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
status.actionRepos = [...actionRepos];
|
|
248
|
+
|
|
249
|
+
// 2. Local pipelines (this machine's own runs).
|
|
250
|
+
const local = localPrs(lookups.map((l) => l.id));
|
|
251
|
+
|
|
252
|
+
// 3. gh, for the repo#branch pairs the Action has not answered.
|
|
253
|
+
const cache = await loadCache();
|
|
254
|
+
const need = new Map(); // key → { repo, branch, endedMs }
|
|
255
|
+
const unsupported = new Set();
|
|
256
|
+
for (const l of lookups) {
|
|
257
|
+
if (!l.branch) continue;
|
|
258
|
+
for (const repo of l.repos) {
|
|
259
|
+
const key = `${lower(repo)}#${l.branch}`;
|
|
260
|
+
if (byBranch.has(key)) continue;
|
|
261
|
+
if (!isGithubSlug(repo)) { if (!actionRepos.has(lower(repo))) unsupported.add(repo); continue; }
|
|
262
|
+
if (cacheFresh(cache.entries[key], { now, runEndedMs: l.endedMs })) continue;
|
|
263
|
+
const prev = need.get(key);
|
|
264
|
+
if (!prev || (l.endedMs ?? 0) > (prev.endedMs ?? 0)) need.set(key, { repo, branch: l.branch, endedMs: l.endedMs });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
status.unsupportedRepos = [...unsupported];
|
|
268
|
+
if (useGh && need.size) {
|
|
269
|
+
const gh = await ghStatus();
|
|
270
|
+
status.gh = gh.state;
|
|
271
|
+
status.ghDetail = gh.detail;
|
|
272
|
+
if (gh.state === 'ok') {
|
|
273
|
+
const { results, error } = await lookupBranchesViaGh([...need.values()]);
|
|
274
|
+
status.ghError = error;
|
|
275
|
+
status.checked = results.size;
|
|
276
|
+
for (const [key, prs] of results) cache.entries[key] = { prs, checkedAt: now };
|
|
277
|
+
if (results.size) await saveCache(cache);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Join per run.
|
|
282
|
+
const prs = {};
|
|
283
|
+
for (const l of lookups) {
|
|
284
|
+
const found = new Map(); // repo#number → pr
|
|
285
|
+
let known = false;
|
|
286
|
+
const add = (p) => {
|
|
287
|
+
if (!p) return;
|
|
288
|
+
const k = `${lower(p.repo)}#${p.number ?? p.url}`;
|
|
289
|
+
found.set(k, better(found.get(k), p));
|
|
290
|
+
};
|
|
291
|
+
// The record's own `pr` carries no state, so on its own it proves nothing about a merge:
|
|
292
|
+
// it only helps to find the PR's event when the Action has written one.
|
|
293
|
+
if (l.pr) {
|
|
294
|
+
const parsed = parsePrUrl(l.pr.url);
|
|
295
|
+
const repo = parsed?.repo || l.repos[0] || null;
|
|
296
|
+
const number = l.pr.number ?? parsed?.number ?? null;
|
|
297
|
+
const ev = repo && number != null ? byNumber.get(`${lower(repo)}#${number}`) : null;
|
|
298
|
+
if (ev) { add(ev); known = true; }
|
|
299
|
+
}
|
|
300
|
+
if (local.has(l.id)) add(local.get(l.id));
|
|
301
|
+
if (l.branch) {
|
|
302
|
+
for (const repo of l.repos) {
|
|
303
|
+
const key = `${lower(repo)}#${l.branch}`;
|
|
304
|
+
const evs = byBranch.get(key);
|
|
305
|
+
const cached = cache.entries[key];
|
|
306
|
+
if (evs) { known = true; evs.forEach(add); }
|
|
307
|
+
else if (cached && Array.isArray(cached.prs)) { known = true; cached.prs.forEach((p) => add({ ...p, via: 'gh' })); }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// A PR known only from this machine's table is still a real PR, but "no PR" is only claimed
|
|
311
|
+
// when the Action or GitHub actually answered for the run's branch.
|
|
312
|
+
prs[l.id] = found.size ? [...found.values()] : known ? [] : null;
|
|
313
|
+
}
|
|
314
|
+
return { prs, status };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export const MAX_LISTED_EVENTS = 5000;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Every PR the Action recorded for the scope's repositories whose life overlaps [from, to): the
|
|
321
|
+
* Timeline shows the ones no recorded run points at as work done outside Worca. Only the
|
|
322
|
+
* Action's files can answer this — gh is asked per branch, never "list everything".
|
|
323
|
+
* @param {object} p
|
|
324
|
+
* @param {string[]} p.sinks metrics slugs whose worktrees hold the events.
|
|
325
|
+
* @param {string[]} p.repos the scope's repositories (slugs); events of other repos are dropped.
|
|
326
|
+
* @returns {Promise<{ prs: object[], truncated: boolean, actionRepos: string[] }>}
|
|
327
|
+
*/
|
|
328
|
+
export async function listPrEvents({ sinks = [], repos = [], from = null, to = null } = {}) {
|
|
329
|
+
const now = _now();
|
|
330
|
+
const want = new Set(repos.map(lower));
|
|
331
|
+
const lo = Number.isFinite(from) ? from : -Infinity;
|
|
332
|
+
const hi = Number.isFinite(to) ? to : Infinity;
|
|
333
|
+
const seen = new Set();
|
|
334
|
+
const out = [];
|
|
335
|
+
const actionRepos = new Set();
|
|
336
|
+
for (const slug of sinks) {
|
|
337
|
+
let dir;
|
|
338
|
+
try { dir = worktreePath(slug); } catch { continue; }
|
|
339
|
+
for (const ev of await readPrEventsFromDir(dir)) {
|
|
340
|
+
const repo = lower(ev.repo);
|
|
341
|
+
if (want.size && !want.has(repo)) continue;
|
|
342
|
+
actionRepos.add(repo);
|
|
343
|
+
const key = `${repo}#${ev.number}`;
|
|
344
|
+
if (seen.has(key)) continue;
|
|
345
|
+
const start = Date.parse(ev.createdAt ?? '');
|
|
346
|
+
const end = Date.parse(ev.mergedAt ?? ev.closedAt ?? '') || now;
|
|
347
|
+
if (!Number.isFinite(start) || start >= hi || end < lo) continue;
|
|
348
|
+
seen.add(key);
|
|
349
|
+
out.push(ev);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
out.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
|
353
|
+
return { prs: out.slice(0, MAX_LISTED_EVENTS), truncated: out.length > MAX_LISTED_EVENTS, actionRepos: [...actionRepos] };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---- the GitHub Action ---------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
export const PR_WORKFLOW_PATH = '.github/workflows/worca-metrics-pr-events.yml';
|
|
359
|
+
export const PR_WORKFLOW_TEMPLATE = new URL('./pr-events-workflow.yml', import.meta.url);
|
|
360
|
+
|
|
361
|
+
export async function prWorkflowText() { return readFile(PR_WORKFLOW_TEMPLATE, 'utf8'); }
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Write the PR-events workflow into a project (`worca metrics pr-workflow`). Never overwrites a
|
|
365
|
+
* file that differs unless `force`: the team may have edited it.
|
|
366
|
+
* @returns {Promise<{ path: string, status: 'created'|'updated'|'unchanged'|'differs' }>}
|
|
367
|
+
*/
|
|
368
|
+
export async function installPrWorkflow(projectDir, { force = false } = {}) {
|
|
369
|
+
const text = await prWorkflowText();
|
|
370
|
+
const path = join(projectDir, ...PR_WORKFLOW_PATH.split('/'));
|
|
371
|
+
const current = await readFile(path, 'utf8').catch(() => null);
|
|
372
|
+
if (current === text) return { path, status: 'unchanged' };
|
|
373
|
+
if (current != null && !force) return { path, status: 'differs' };
|
|
374
|
+
await mkdir(join(projectDir, '.github', 'workflows'), { recursive: true });
|
|
375
|
+
await writeFile(path, text);
|
|
376
|
+
return { path, status: current == null ? 'created' : 'updated' };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export const _testing = {
|
|
380
|
+
setRunner(fn) { _run = typeof fn === 'function' ? fn : defaultRun; _ghStatus = null; },
|
|
381
|
+
setNow(fn) { _now = typeof fn === 'function' ? fn : () => Date.now(); },
|
|
382
|
+
reset() { _run = defaultRun; _now = () => Date.now(); _ghStatus = null; },
|
|
383
|
+
};
|
|
@@ -212,35 +212,7 @@ function syncFor(slug, prefs, pending) {
|
|
|
212
212
|
* refresh:{requested,fetched,limited,retryInMs}, fetchError:string|null}>}
|
|
213
213
|
*/
|
|
214
214
|
export async function readScope(scope, { refresh = false, defer = false } = {}) {
|
|
215
|
-
const sources =
|
|
216
|
-
let meta;
|
|
217
|
-
let rateProjectDir = null;
|
|
218
|
-
if (scope.kind === 'project') {
|
|
219
|
-
const p = (await listProjects()).find((x) => x.key === scope.id);
|
|
220
|
-
if (!p) throw Object.assign(new Error(`unknown project ${scope.id}`), { code: 'NOT_FOUND' });
|
|
221
|
-
rateProjectDir = p.path;
|
|
222
|
-
const sink = await resolveProjectSink(p.path);
|
|
223
|
-
if (!sink.ok) {
|
|
224
|
-
const code = sink.reason === 'delegate-invalid' ? 'DELEGATE_INVALID' : 'NOT_ENABLED';
|
|
225
|
-
throw Object.assign(new Error(sink.detail || `team metrics are not enabled for ${p.name}`), { code });
|
|
226
|
-
}
|
|
227
|
-
const ownSlug = sink.from;
|
|
228
|
-
sources.push({ slug: sink.slug, projectDir: sink.projectDir, keep: (r) => r.target?.kind === 'project' && r.target.project === ownSlug });
|
|
229
|
-
meta = { kind: 'project', id: p.key, name: p.name, slug: ownSlug, recordedIn: sink.delegated ? sink.slug : null };
|
|
230
|
-
} else {
|
|
231
|
-
const ws = await readWorkspace(scope.id);
|
|
232
|
-
if (!ws) throw Object.assign(new Error(`unknown workspace ${scope.id}`), { code: 'NOT_FOUND' });
|
|
233
|
-
const name = ws.name.toLowerCase();
|
|
234
|
-
for (const path of ws.projectPaths) {
|
|
235
|
-
const prefs = (await discoverProject(path).catch(() => null)) || readTeamMetricsPrefs(projectKey(path));
|
|
236
|
-
if (!recordsLocally(prefs)) continue; // only members that record locally (config read, not a marker)
|
|
237
|
-
sources.push({ slug: prefs.slug, projectDir: path, keep: (r) => matchesWorkspace(r.target, ws) });
|
|
238
|
-
}
|
|
239
|
-
if (!sources.length) throw Object.assign(new Error(`no member of ${ws.name} records team metrics`), { code: 'NOT_ENABLED' });
|
|
240
|
-
rateProjectDir = ws.metricsProject || null;
|
|
241
|
-
const homePrefs = ws.metricsProject ? readTeamMetricsPrefs(projectKey(ws.metricsProject)) : null;
|
|
242
|
-
meta = { kind: 'workspace', id: ws.id, name: ws.name, home: homePrefs?.slug ?? null, sources: sources.map((s) => s.slug) };
|
|
243
|
-
}
|
|
215
|
+
const { sources, meta, rateProjectDir } = await scopeSources(scope);
|
|
244
216
|
const seen = new Set();
|
|
245
217
|
const records = [];
|
|
246
218
|
const stats = { files: 0, malformed: 0, unknownV: 0 };
|
|
@@ -276,6 +248,44 @@ export async function readScope(scope, { refresh = false, defer = false } = {})
|
|
|
276
248
|
return { scope: meta, records, stats, sinks: sources.map((s) => s.slug), sync, refresh: refreshInfo, fetchError, rateProjectDir };
|
|
277
249
|
}
|
|
278
250
|
|
|
251
|
+
/**
|
|
252
|
+
* The metrics branches a scope reads (no git, no record parsing): a project's own sink (or the
|
|
253
|
+
* one it delegates to), or every workspace member that records locally.
|
|
254
|
+
* @returns {Promise<{sources: {slug, projectDir, keep}[], meta: object, rateProjectDir: string|null}>}
|
|
255
|
+
*/
|
|
256
|
+
export async function scopeSources(scope) {
|
|
257
|
+
const sources = [];
|
|
258
|
+
let meta;
|
|
259
|
+
let rateProjectDir = null;
|
|
260
|
+
if (scope.kind === 'project') {
|
|
261
|
+
const p = (await listProjects()).find((x) => x.key === scope.id);
|
|
262
|
+
if (!p) throw Object.assign(new Error(`unknown project ${scope.id}`), { code: 'NOT_FOUND' });
|
|
263
|
+
rateProjectDir = p.path;
|
|
264
|
+
const sink = await resolveProjectSink(p.path);
|
|
265
|
+
if (!sink.ok) {
|
|
266
|
+
const code = sink.reason === 'delegate-invalid' ? 'DELEGATE_INVALID' : 'NOT_ENABLED';
|
|
267
|
+
throw Object.assign(new Error(sink.detail || `team metrics are not enabled for ${p.name}`), { code });
|
|
268
|
+
}
|
|
269
|
+
const ownSlug = sink.from;
|
|
270
|
+
sources.push({ slug: sink.slug, projectDir: sink.projectDir, keep: (r) => r.target?.kind === 'project' && r.target.project === ownSlug });
|
|
271
|
+
meta = { kind: 'project', id: p.key, name: p.name, slug: ownSlug, recordedIn: sink.delegated ? sink.slug : null };
|
|
272
|
+
} else {
|
|
273
|
+
const ws = await readWorkspace(scope.id);
|
|
274
|
+
if (!ws) throw Object.assign(new Error(`unknown workspace ${scope.id}`), { code: 'NOT_FOUND' });
|
|
275
|
+
const name = ws.name.toLowerCase();
|
|
276
|
+
for (const path of ws.projectPaths) {
|
|
277
|
+
const prefs = (await discoverProject(path).catch(() => null)) || readTeamMetricsPrefs(projectKey(path));
|
|
278
|
+
if (!recordsLocally(prefs)) continue; // only members that record locally (config read, not a marker)
|
|
279
|
+
sources.push({ slug: prefs.slug, projectDir: path, keep: (r) => matchesWorkspace(r.target, ws) });
|
|
280
|
+
}
|
|
281
|
+
if (!sources.length) throw Object.assign(new Error(`no member of ${ws.name} records team metrics`), { code: 'NOT_ENABLED' });
|
|
282
|
+
rateProjectDir = ws.metricsProject || null;
|
|
283
|
+
const homePrefs = ws.metricsProject ? readTeamMetricsPrefs(projectKey(ws.metricsProject)) : null;
|
|
284
|
+
meta = { kind: 'workspace', id: ws.id, name: ws.name, home: homePrefs?.slug ?? null, sources: sources.map((s) => s.slug) };
|
|
285
|
+
}
|
|
286
|
+
return { sources, meta, rateProjectDir };
|
|
287
|
+
}
|
|
288
|
+
|
|
279
289
|
/** Scope list + Projects/Workspaces status in one call (Scope select, Projects cells, ws cards, Stats hint). */
|
|
280
290
|
export async function listScopes({ discover = false } = {}) {
|
|
281
291
|
const recordsBySink = new Map();
|
|
@@ -11,7 +11,7 @@ import { readPrState } from '../artifacts.mjs';
|
|
|
11
11
|
import { RESULTS_FILE } from '../results.mjs';
|
|
12
12
|
import { UI_PHASE } from '../../shared/graph/manifest.mjs';
|
|
13
13
|
import {
|
|
14
|
-
projectSlug, gitUserName, resolveProjectSink, resolveWorkspaceSink, writeOutbox, scheduleFlush as realScheduleFlush,
|
|
14
|
+
projectSlug, gitUserName, gitUserEmail, resolveProjectSink, resolveWorkspaceSink, writeOutbox, scheduleFlush as realScheduleFlush,
|
|
15
15
|
} from './sync.mjs';
|
|
16
16
|
import { writeRunLedger } from './ledger.mjs';
|
|
17
17
|
import { readPolicyState } from '../policy/state.mjs';
|
|
@@ -231,6 +231,9 @@ export function buildRunRecord(snap, { attribution = 'git-user', now = new Date(
|
|
|
231
231
|
deletions: num(g.deletions),
|
|
232
232
|
},
|
|
233
233
|
actor: attribution === 'none' ? null : cleanText(snap.actor),
|
|
234
|
+
// Additive: a stable, host-neutral key for the person (personKey of the git email), so the
|
|
235
|
+
// Timeline can match a run's actor to the author of a pull request made outside Worca.
|
|
236
|
+
...(attribution !== 'none' && PERSON_KEY_RE.test(String(snap.actorKey || '')) ? { actorKey: snap.actorKey } : {}),
|
|
234
237
|
// Present ONLY on runs that saw a policy: records of policy-less runs stay byte-identical to v1.
|
|
235
238
|
...((p) => (p ? { policy: p } : {}))(buildPolicy(snap.policy, attribution)),
|
|
236
239
|
...((h) => (h ? { human: h } : {}))(buildHuman(agentSteps, snap.humanHours)),
|
|
@@ -372,7 +375,11 @@ export async function snapshotFromHarness(harness, { status, error = null } = {}
|
|
|
372
375
|
lastPause: iv.lastPauseReason
|
|
373
376
|
? { reason: iv.lastPauseReason, detail: iv.lastPauseDetail ?? null }
|
|
374
377
|
: harness.pauseReason ? { reason: harness.pauseReason, detail: harness.pauseDetail ?? null } : null,
|
|
375
|
-
|
|
378
|
+
// The person who started the run (identity.mjs) when one is known; else, as before, the
|
|
379
|
+
// checkout's git user (a local install, the CLI). attribution:'none' still drops both.
|
|
380
|
+
actor: actorForRecord(harness.state && harness.state.startedBy) ?? await gitUserName(harness.projectDir),
|
|
381
|
+
// Only for the git user: a person named by startedBy (identity.mjs) has no email here.
|
|
382
|
+
actorKey: actorForRecord(harness.state && harness.state.startedBy) ? null : personKey(await gitUserEmail(harness.projectDir)),
|
|
376
383
|
// The run's policy state (pipelines.policy_state) as the gates and the resume flow left it;
|
|
377
384
|
// `unattended` is the harness's own auto flag, which the record needs even when nothing else
|
|
378
385
|
// was written (a --yes run that stayed under every cap still carries no state row).
|
|
@@ -380,6 +387,27 @@ export async function snapshotFromHarness(harness, { status, error = null } = {}
|
|
|
380
387
|
};
|
|
381
388
|
}
|
|
382
389
|
|
|
390
|
+
export const PERSON_KEY_RE = /^[0-9a-f]{16}$/;
|
|
391
|
+
/** Emails that name a machine, not a person: Worca's own run commits (run-harness.mjs) and the
|
|
392
|
+
* metrics fallback identity. The PR-events Action skips the same ones. */
|
|
393
|
+
const MACHINE_EMAIL_RE = /@local$/i;
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* A person's host-neutral key: sha256("worca:" + lower-cased email), first 16 hex. The same on
|
|
397
|
+
* every machine and every host (the PR-events Action computes it from commit authors), and no
|
|
398
|
+
* plain address is stored. null for no email or a machine identity.
|
|
399
|
+
*/
|
|
400
|
+
export function personKey(email) {
|
|
401
|
+
const e = typeof email === 'string' ? email.trim().toLowerCase() : '';
|
|
402
|
+
if (!e || !e.includes('@') || MACHINE_EMAIL_RE.test(e)) return null;
|
|
403
|
+
return createHash('sha256').update(`worca:${e}`).digest('hex').slice(0, 16);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** A recorded person (not 'local', not empty) for the metrics actor, or null to fall back. */
|
|
407
|
+
export function actorForRecord(startedBy) {
|
|
408
|
+
return typeof startedBy === 'string' && startedBy.trim() && startedBy !== 'local' ? startedBy.trim() : null;
|
|
409
|
+
}
|
|
410
|
+
|
|
383
411
|
let _recorder = null;
|
|
384
412
|
let scheduleFlush = realScheduleFlush;
|
|
385
413
|
|
|
@@ -9,6 +9,19 @@ import { randomBytes } from 'node:crypto';
|
|
|
9
9
|
import { worcaHome, listProjects } from '../projects.mjs';
|
|
10
10
|
import { projectKey, canonicalProjectRoot } from '../store.mjs';
|
|
11
11
|
import { listRemotes, parseRemoteUrl } from '../git-info.mjs';
|
|
12
|
+
import { githubEnv, readGithubCredentials, stripGithubCredentials } from '../github-credentials.mjs';
|
|
13
|
+
|
|
14
|
+
const NETWORK_GIT = new Set(['fetch', 'push', 'ls-remote', 'clone', 'pull']);
|
|
15
|
+
|
|
16
|
+
/** "owner/name" of origin on github.com, only in App mode (the one mode that needs it). */
|
|
17
|
+
async function originRepo(cwd) {
|
|
18
|
+
if (readGithubCredentials().mode !== 'app' || !cwd) return null;
|
|
19
|
+
try {
|
|
20
|
+
const url = await new Promise((ok) => execFile('git', ['remote', 'get-url', 'origin'], { cwd, timeout: 10_000 }, (e, out) => ok(e ? '' : String(out).trim())));
|
|
21
|
+
const p = parseRemoteUrl(url);
|
|
22
|
+
return p && p.host === 'github.com' ? `${p.owner}/${p.repo}` : null;
|
|
23
|
+
} catch { return null; }
|
|
24
|
+
}
|
|
12
25
|
import { readTeamMetricsPrefs, writeTeamMetricsPrefs } from '../config.mjs';
|
|
13
26
|
import { readWorkspace, listWorkspaces, isGitRepo } from '../workspaces.mjs';
|
|
14
27
|
import { withLock } from './lock.mjs';
|
|
@@ -69,8 +82,19 @@ function hookFreeArgs() {
|
|
|
69
82
|
return ['-c', `core.hooksPath=${dir}`, '-c', 'commit.gpgsign=false', '-c', 'push.gpgSign=false'];
|
|
70
83
|
}
|
|
71
84
|
|
|
72
|
-
function defaultGit(cwd, args, { timeoutMs = 60_000, env = null } = {}) {
|
|
73
|
-
|
|
85
|
+
async function defaultGit(cwd, args, { timeoutMs = 60_000, env = null } = {}) {
|
|
86
|
+
// The write credential (it reads too): metrics and policy branches are fetched and pushed.
|
|
87
|
+
// In App mode a fresh token per call; a failed mint degrades to no credential (git then
|
|
88
|
+
// reports the auth failure the sync already handles) and is logged once per call.
|
|
89
|
+
// Local commands (commit, worktree, read-tree…) get no credential at all.
|
|
90
|
+
let base;
|
|
91
|
+
if (NETWORK_GIT.has(args[0])) {
|
|
92
|
+
const cred = await githubEnv('write', { repo: await originRepo(cwd) });
|
|
93
|
+
if (cred.error) console.warn(`[worca] metrics/policy git ${args[0]}: ${cred.error}`);
|
|
94
|
+
base = cred.env;
|
|
95
|
+
} else {
|
|
96
|
+
base = stripGithubCredentials(process.env);
|
|
97
|
+
}
|
|
74
98
|
for (const k of STRIP_ENV) delete base[k];
|
|
75
99
|
return new Promise((done) => {
|
|
76
100
|
execFile('git', [...hookFreeArgs(), ...args], {
|
|
@@ -148,6 +172,12 @@ export async function gitUserName(dir) {
|
|
|
148
172
|
return r.ok ? r.stdout.trim() || null : null;
|
|
149
173
|
}
|
|
150
174
|
|
|
175
|
+
/** `git config user.email` — only ever hashed into a record (record.mjs personKey), never stored. */
|
|
176
|
+
export async function gitUserEmail(dir) {
|
|
177
|
+
const r = await _git(dir, ['config', 'user.email'], { timeoutMs: 10_000 });
|
|
178
|
+
return r.ok ? r.stdout.trim() || null : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
151
181
|
/** `-c` identity args for metrics commits (§4.5: git user, or Worca under attribution:none).
|
|
152
182
|
* A name without an email would make `git commit` fail, so both must be set to use the git user.
|
|
153
183
|
* Exported for the team-policy branch (policy/sync.mjs), which commits under the same rules. */
|
|
@@ -515,7 +545,7 @@ async function validateDelegateTarget(delegateTo, ownSlug) {
|
|
|
515
545
|
* mode 'delegate' → create a marker branch {delegateTo}; or join; with change:true rewrite an existing marker.
|
|
516
546
|
* @returns {Promise<{action:'created'|'joined'|'changed', slug:string, config:object|null}>}
|
|
517
547
|
*/
|
|
518
|
-
export async function enableTeamMetrics(projectDir, { mode = 'here', attribution = 'git-user', delegateTo = null, change = false, now = new Date() } = {}) {
|
|
548
|
+
export async function enableTeamMetrics(projectDir, { mode = 'here', attribution = 'git-user', delegateTo = null, change = false, now = new Date(), by = null } = {}) {
|
|
519
549
|
if (mode !== 'here' && mode !== 'delegate') throw metricsError('BAD_REQUEST', `mode must be "here" or "delegate"`);
|
|
520
550
|
const { slug, hasOrigin } = await projectSlug(projectDir);
|
|
521
551
|
if (!hasOrigin) throw metricsError('NO_ORIGIN', 'this project has no origin remote — team metrics push to origin, so there is nowhere to record');
|
|
@@ -526,7 +556,8 @@ export async function enableTeamMetrics(projectDir, { mode = 'here', attribution
|
|
|
526
556
|
// every member's marker still carried the developer's real git name, and "Route all members"
|
|
527
557
|
// (which passes no attribution at all) did it for the whole workspace in one click.
|
|
528
558
|
const attr = mode === 'delegate' ? delegate.attribution : normAttribution(attribution);
|
|
529
|
-
|
|
559
|
+
// The person who asked (identity.mjs) when known; else the git user. attribution:'none' still drops it.
|
|
560
|
+
const user = (typeof by === 'string' && by.trim() && by !== 'local' ? by.trim() : null) ?? await gitUserName(projectDir);
|
|
530
561
|
const config = mode === 'delegate'
|
|
531
562
|
// enabledBy follows the same policy as the commit identity (decision 13): anonymising the
|
|
532
563
|
// marker's author while leaving the developer's name in its JSON body would defeat the point.
|