@stravica-ai/digital-operator-pa 0.1.42 → 0.1.44
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/dist/engineering-pa-cli/src/pa/cli.mjs +4 -2
- package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-server.md +1 -1
- package/dist/engineering-pa-core-extensions-atlassian/package.json +1 -1
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +3 -23
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +69 -26
- package/dist/engineering-pa-library-cli/README.md +59 -0
- package/dist/engineering-pa-library-cli/src/cli.mjs +41 -1
- package/dist/engineering-pa-library-cli/src/up-merge/github.mjs +103 -0
- package/dist/engineering-pa-library-cli/src/up-merge/scope.mjs +95 -0
- package/dist/engineering-pa-library-cli/src/verbs/up-merge.mjs +290 -0
- package/dist/engineering-pa-scaffold/package.json +1 -1
- package/dist/engineering-pa-scaffold/src/commands/init.mjs +12 -10
- package/dist/engineering-pa-scaffold/src/generator.mjs +33 -23
- package/dist/engineering-pa-workspace/README.md +6 -4
- package/dist/engineering-pa-workspace/docs/DAEMON.md +19 -4
- package/dist/engineering-pa-workspace/docs/status-mon.md +1 -1
- package/dist/engineering-pa-workspace/src/install-daemon.mjs +289 -78
- package/dist/engineering-pa-workspace/src/supervisor.mjs +74 -13
- package/dist/engineering-pa-workspace/src/unit-name.mjs +113 -0
- package/dist/engineering-pa-workspace/src/ws.mjs +17 -5
- package/docs/onboarding-solo.md +23 -5
- package/package.json +1 -1
|
@@ -360,9 +360,11 @@ Usage:
|
|
|
360
360
|
Bind host defaults to loopback (127.0.0.1). Use --host <addr> (or the
|
|
361
361
|
WORKSPACE_HOST env var) to expose the workspace on another interface.
|
|
362
362
|
|
|
363
|
-
When the workspace is supervised by launchd (~/Library/LaunchAgents/com
|
|
364
|
-
or systemd (~/.config/systemd/user
|
|
363
|
+
When the workspace is supervised by launchd (~/Library/LaunchAgents/com.<paName>.workspace.plist)
|
|
364
|
+
or systemd (~/.config/systemd/user/<paName>-workspace.service), \`stop\` is refused and \`restart\`
|
|
365
365
|
delegates to the supervisor — this is the in-place version-pickup path after \`pnpm update\`.
|
|
366
|
+
Unit labels are derived from operator/config/preferences.json:paName; the legacy
|
|
367
|
+
com.dave.workspace / dave-workspace.service is still recognised for pre-0.1.44 installs.
|
|
366
368
|
|
|
367
369
|
Exit codes (tooling standard §6):
|
|
368
370
|
0 ok | 1 usage error | 2 operational failure | 3 transport/IO failure
|
|
@@ -33,7 +33,7 @@ The workspace server hosts `viewer.html` — the PA's canonical surface for long
|
|
|
33
33
|
|
|
34
34
|
## Supervisor-aware lifecycle
|
|
35
35
|
|
|
36
|
-
When the workspace is supervised by launchd (`~/Library/LaunchAgents/com
|
|
36
|
+
When the workspace is supervised by launchd (`~/Library/LaunchAgents/com.<paName>.workspace.plist`) or systemd (`~/.config/systemd/user/<paName>-workspace.service`), `stop` is refused and `restart` delegates to the supervisor. This is the in-place version-pickup path after `pnpm update @stravica-ai/digital-operator-pa`. Unit labels follow the scheduler's `com.{agent}.{id}` convention with `id = workspace` and `agent` resolved from `paName` in `operator/config/preferences.json`; pre-0.1.44 installs are still recognised under the legacy `com.dave.workspace` / `dave-workspace.service` name until you run `install-daemon install --migrate` to rename.
|
|
37
37
|
|
|
38
38
|
## When to use the workspace
|
|
39
39
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stravica-ai/engineering-pa-core-extensions-atlassian",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Atlassian (Jira, Confluence, confluence-fetch) CLIs and library surface. Promoted into core for the alpha solo scaffold.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// Zero external dependencies - uses Node.js built-in fetch() and child_process.
|
|
10
10
|
|
|
11
11
|
import { loadAtlassianCredentials } from '../common/secrets.mjs';
|
|
12
|
+
import { readBody, formatHttpError } from '../jira/client.mjs';
|
|
12
13
|
|
|
13
14
|
// ---- Exit-code-aware error ----------------------------------------------
|
|
14
15
|
|
|
@@ -145,33 +146,12 @@ export function createClient(options = {}) {
|
|
|
145
146
|
// Parse response
|
|
146
147
|
let data = null;
|
|
147
148
|
if (res.status !== 204) {
|
|
148
|
-
|
|
149
|
-
if (contentType.includes('application/json')) {
|
|
150
|
-
data = await res.json();
|
|
151
|
-
} else {
|
|
152
|
-
const text = await res.text();
|
|
153
|
-
if (text) data = text;
|
|
154
|
-
}
|
|
149
|
+
data = await readBody(res);
|
|
155
150
|
}
|
|
156
151
|
|
|
157
152
|
// 4xx errors
|
|
158
153
|
if (res.status >= 400) {
|
|
159
|
-
|
|
160
|
-
if (data && data.errorMessages) msgs.push(...data.errorMessages);
|
|
161
|
-
if (data && data.errors) {
|
|
162
|
-
if (Array.isArray(data.errors)) {
|
|
163
|
-
for (const e of data.errors) {
|
|
164
|
-
msgs.push(e.title || e.message || JSON.stringify(e));
|
|
165
|
-
}
|
|
166
|
-
} else {
|
|
167
|
-
for (const [field, msg] of Object.entries(data.errors)) {
|
|
168
|
-
msgs.push(`${field}: ${msg}`);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
if (data && data.message) msgs.push(data.message);
|
|
173
|
-
const detail = msgs.length > 0 ? msgs.join('; ') : `HTTP ${res.status}`;
|
|
174
|
-
throw new ConfluenceError(`Confluence API error (${res.status}): ${detail}`, 2);
|
|
154
|
+
throw new ConfluenceError(formatHttpError('Confluence', res.status, data), 2);
|
|
175
155
|
}
|
|
176
156
|
|
|
177
157
|
return { status: res.status, data, headers: res.headers };
|
|
@@ -17,6 +17,71 @@ export class JiraError extends Error {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// ---- Shared response parsing --------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read a fetch Response body without ever throwing on a bad body.
|
|
24
|
+
*
|
|
25
|
+
* Atlassian sometimes returns a non-JSON body (bare text, an HTML error
|
|
26
|
+
* page from Cloudflare, a proxy edge string) with a `Content-Type` that
|
|
27
|
+
* still says `application/json`, and a raw `res.json()` on that body throws
|
|
28
|
+
* `SyntaxError: Unexpected token 'C', "Client mus"... is not valid JSON`,
|
|
29
|
+
* which surfaces as an unhandled stack trace instead of an
|
|
30
|
+
* "authentication failed" message. Falls back to raw text on any parse
|
|
31
|
+
* failure and returns null for empty bodies.
|
|
32
|
+
*
|
|
33
|
+
* @param {Response} res
|
|
34
|
+
* @returns {Promise<any|string|null>}
|
|
35
|
+
*/
|
|
36
|
+
export async function readBody(res) {
|
|
37
|
+
const contentType = res.headers.get('Content-Type') || '';
|
|
38
|
+
const wantsJson = contentType.includes('application/json');
|
|
39
|
+
const raw = await res.text();
|
|
40
|
+
if (!raw) return null;
|
|
41
|
+
if (wantsJson) {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(raw);
|
|
44
|
+
} catch {
|
|
45
|
+
return raw.trim();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return raw.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Turn a parsed error body into a one-line, colleague-facing HTTP
|
|
53
|
+
* error message. `data` may be a parsed JSON object (Atlassian error
|
|
54
|
+
* shape), a plain string (non-JSON body), or null.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} product "Jira" or "Confluence"
|
|
57
|
+
* @param {number} status HTTP status
|
|
58
|
+
* @param {any} data Parsed body, string, or null
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function formatHttpError(product, status, data) {
|
|
62
|
+
const parts = [];
|
|
63
|
+
if (data && typeof data === 'object') {
|
|
64
|
+
if (Array.isArray(data.errorMessages)) parts.push(...data.errorMessages);
|
|
65
|
+
if (data.errors) {
|
|
66
|
+
if (Array.isArray(data.errors)) {
|
|
67
|
+
for (const e of data.errors) {
|
|
68
|
+
parts.push(e.title || e.message || JSON.stringify(e));
|
|
69
|
+
}
|
|
70
|
+
} else if (typeof data.errors === 'object') {
|
|
71
|
+
for (const [field, msg] of Object.entries(data.errors)) {
|
|
72
|
+
parts.push(`${field}: ${msg}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (data.message && parts.length === 0) parts.push(data.message);
|
|
77
|
+
} else if (typeof data === 'string' && data.length > 0) {
|
|
78
|
+
const trimmed = data.length > 120 ? `${data.slice(0, 120)}...` : data;
|
|
79
|
+
parts.push(trimmed);
|
|
80
|
+
}
|
|
81
|
+
const detail = parts.length > 0 ? parts.join('; ') : `HTTP ${status}`;
|
|
82
|
+
return `${product} API error (${status}): ${detail}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
20
85
|
// ---- Credential loading -------------------------------------------------
|
|
21
86
|
|
|
22
87
|
/**
|
|
@@ -142,26 +207,12 @@ export function createClient(options = {}) {
|
|
|
142
207
|
// Parse response
|
|
143
208
|
let data = null;
|
|
144
209
|
if (res.status !== 204) {
|
|
145
|
-
|
|
146
|
-
if (contentType.includes('application/json')) {
|
|
147
|
-
data = await res.json();
|
|
148
|
-
} else {
|
|
149
|
-
const text = await res.text();
|
|
150
|
-
if (text) data = text;
|
|
151
|
-
}
|
|
210
|
+
data = await readBody(res);
|
|
152
211
|
}
|
|
153
212
|
|
|
154
213
|
// 4xx errors
|
|
155
214
|
if (res.status >= 400) {
|
|
156
|
-
|
|
157
|
-
if (data && data.errorMessages) msgs.push(...data.errorMessages);
|
|
158
|
-
if (data && data.errors) {
|
|
159
|
-
for (const [field, msg] of Object.entries(data.errors)) {
|
|
160
|
-
msgs.push(`${field}: ${msg}`);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
const detail = msgs.length > 0 ? msgs.join('; ') : `HTTP ${res.status}`;
|
|
164
|
-
throw new JiraError(`Jira API error (${res.status}): ${detail}`, 2);
|
|
215
|
+
throw new JiraError(formatHttpError('Jira', res.status, data), 2);
|
|
165
216
|
}
|
|
166
217
|
|
|
167
218
|
return { status: res.status, data, headers: res.headers };
|
|
@@ -309,19 +360,11 @@ export function createClient(options = {}) {
|
|
|
309
360
|
|
|
310
361
|
let data = null;
|
|
311
362
|
if (res.status !== 204) {
|
|
312
|
-
|
|
313
|
-
if (contentType.includes('application/json')) data = await res.json();
|
|
314
|
-
else { const text = await res.text(); if (text) data = text; }
|
|
363
|
+
data = await readBody(res);
|
|
315
364
|
}
|
|
316
365
|
|
|
317
366
|
if (res.status >= 400) {
|
|
318
|
-
|
|
319
|
-
if (data && data.errorMessages) msgs.push(...data.errorMessages);
|
|
320
|
-
if (data && data.errors) {
|
|
321
|
-
for (const [field, msg] of Object.entries(data.errors)) msgs.push(`${field}: ${msg}`);
|
|
322
|
-
}
|
|
323
|
-
const detail = msgs.length > 0 ? msgs.join('; ') : `HTTP ${res.status}`;
|
|
324
|
-
throw new JiraError(`Jira API error (${res.status}): ${detail}`, 2);
|
|
367
|
+
throw new JiraError(formatHttpError('Jira', res.status, data), 2);
|
|
325
368
|
}
|
|
326
369
|
|
|
327
370
|
return { status: res.status, data, headers: res.headers };
|
|
@@ -27,6 +27,31 @@ deliberately not a shim for the pre-remote library sweep.
|
|
|
27
27
|
- `library status [--json]`
|
|
28
28
|
GETs `/status` from the service, plus `git rev-list HEAD..origin/main --count`
|
|
29
29
|
per tier clone.
|
|
30
|
+
- `library up-merge [--repo <owner/name>]... [--pr <number>] [--dry-run] [--once] [--verbose] [--json]`
|
|
31
|
+
Scheduled auto-merger for library up-path PRs. Scans configured library
|
|
32
|
+
repos for open PRs from branch `library-sync-up/<device>` and merges each
|
|
33
|
+
one whose diff is confined to the paths `<device>` owns per
|
|
34
|
+
`operator/registry/repos.json`. Anything else is a refusal: the PR is
|
|
35
|
+
left open with a one-time comment (idempotent via an HTML marker) and no
|
|
36
|
+
merge is ever attempted. Writes one JSONL line per PR per pass to
|
|
37
|
+
`library.upMerge.eventsLog` (default `.cache/library-up-merger/events.jsonl`).
|
|
38
|
+
Runs the same in SIMPLE and REMOTE mode (GitHub-side maintenance verb,
|
|
39
|
+
no ingest touch). Config in the `library.upMerge` stanza; see below.
|
|
40
|
+
|
|
41
|
+
### Scope rule (verbatim)
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
A PR is eligible when both:
|
|
45
|
+
(1) its head branch matches "library-sync-up/<device>"
|
|
46
|
+
(2) every changed file's repo-relative path falls under a path <device>
|
|
47
|
+
owns for that library repo per operator/registry/repos.json:
|
|
48
|
+
for each mount M where M.upPath === true and M.upPathOwner === <device>:
|
|
49
|
+
allowed prefix = M.from + include, for each include in M.upPathInclude
|
|
50
|
+
a file matches when its path equals a prefix (single-file include)
|
|
51
|
+
or starts with prefix + "/" (directory include).
|
|
52
|
+
Any file outside the device's allowed prefixes is a refusal - the PR is
|
|
53
|
+
left open, a one-time comment is posted, and no merge is ever attempted.
|
|
54
|
+
```
|
|
30
55
|
|
|
31
56
|
## Configuration
|
|
32
57
|
|
|
@@ -45,6 +70,40 @@ deliberately not a shim for the pre-remote library sweep.
|
|
|
45
70
|
|
|
46
71
|
- `LIBRARY_PULL_CLONES_ROOT` env — overrides the pull-clones root at runtime.
|
|
47
72
|
|
|
73
|
+
- `library.upMerge` stanza in preferences (used by `library up-merge` only):
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"library": {
|
|
78
|
+
"upMerge": {
|
|
79
|
+
"repos": ["Stravica/stravica-library-wsd"],
|
|
80
|
+
"registryPath": "operator/registry/repos.json",
|
|
81
|
+
"eventsLog": ".cache/library-up-merger/events.jsonl",
|
|
82
|
+
"mergeMethod": "merge",
|
|
83
|
+
"branchPrefix": "library-sync-up/",
|
|
84
|
+
"deleteBranchOnMerge": true,
|
|
85
|
+
"ghBin": "gh"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Every field has a safe default; `repos` defaults to `[]` (no repos scanned)
|
|
92
|
+
and can be overridden per invocation with `--repo <owner/name>` (repeatable).
|
|
93
|
+
|
|
94
|
+
### Migrating from the HQ `library-up-merger` tool
|
|
95
|
+
|
|
96
|
+
`library up-merge` is a behaviour-identical promotion of the HQ tool at
|
|
97
|
+
`operator/extensions/tools/library-up-merger/`. The scheduled command
|
|
98
|
+
changes from
|
|
99
|
+
`node operator/extensions/tools/library-up-merger/bin/library-up-merger`
|
|
100
|
+
to `pnpm exec library up-merge`. The scope rule, refusal marker, events
|
|
101
|
+
log path, and default merge method are unchanged; the HQ tool's
|
|
102
|
+
`config.json` maps 1:1 to the `library.upMerge` stanza above (`repos`,
|
|
103
|
+
`registryPath`, `eventsLog`, `mergeMethod`, `branchPrefix`,
|
|
104
|
+
`deleteBranchOnMerge`). Repoint the schedule at the new command and
|
|
105
|
+
retire the HQ tool.
|
|
106
|
+
|
|
48
107
|
Ingest authenticates by subkey alone (spec §3.4, main ruling 2026-08-20 option b);
|
|
49
108
|
no install-key header is sent. The former `REMOTE_LIBRARY_INSTALL_KEY` env
|
|
50
109
|
interim was removed in the same pass.
|
|
@@ -22,6 +22,7 @@ import { runGet } from './verbs/get.mjs';
|
|
|
22
22
|
import { runSearch } from './verbs/search.mjs';
|
|
23
23
|
import { runStatus } from './verbs/status.mjs';
|
|
24
24
|
import { runRefreshClones } from './verbs/refresh-clones.mjs';
|
|
25
|
+
import { runUpMerge } from './verbs/up-merge.mjs';
|
|
25
26
|
|
|
26
27
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
28
|
|
|
@@ -52,13 +53,15 @@ Usage:
|
|
|
52
53
|
library search <query> [--files-only]
|
|
53
54
|
library status [--json]
|
|
54
55
|
library refresh-clones [--json] [--timeout-ms <n>] [--tier <name>]...
|
|
56
|
+
library up-merge [--repo <owner/name>]... [--pr <number>] [--dry-run] [--once] [--verbose] [--json]
|
|
55
57
|
library -h | --help
|
|
56
58
|
library -v | --version
|
|
57
59
|
|
|
58
60
|
Reads config from operator/config/preferences.json (+ preferences.local.json overlay).
|
|
59
61
|
Verbs are only active in REMOTE mode. SIMPLE mode 'library status' prints a
|
|
60
62
|
one-line mode summary and exits 0; every other verb exits 1 in SIMPLE mode
|
|
61
|
-
with a clean not-applicable message.
|
|
63
|
+
with a clean not-applicable message. 'library up-merge' is a GitHub-side
|
|
64
|
+
maintenance verb and runs in both SIMPLE and REMOTE mode.
|
|
62
65
|
|
|
63
66
|
--help and --version never touch config or the vault, so they always work
|
|
64
67
|
from a directory with no operator config and no vault installed.
|
|
@@ -102,6 +105,18 @@ export async function main(argv, seams = {}) {
|
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
if (config.mode === 'simple') {
|
|
108
|
+
// `library up-merge` is a GitHub-side maintenance verb: it reads
|
|
109
|
+
// library.upMerge from preferences and hits gh, never the ingest
|
|
110
|
+
// service. It runs the same in SIMPLE and REMOTE mode.
|
|
111
|
+
if (verb === 'up-merge') {
|
|
112
|
+
const ctxUp = { config, log, err };
|
|
113
|
+
try {
|
|
114
|
+
return await runUpMerge({ ...ctxUp, args: parseUpMerge(rest) });
|
|
115
|
+
} catch (e) {
|
|
116
|
+
err(`[library up-merge] ${e.message ?? e}`);
|
|
117
|
+
return typeof e.exitCode === 'number' ? e.exitCode : 2;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
105
120
|
// `library status` is the one verb useful in SIMPLE mode too: a plain
|
|
106
121
|
// one-line mode summary that tells the operator the CLI is installed
|
|
107
122
|
// and the remote path is intentionally not wired. No vault touch,
|
|
@@ -141,6 +156,7 @@ export async function main(argv, seams = {}) {
|
|
|
141
156
|
case 'search': return await runSearch({ ...ctx, args: parseSearch(rest) });
|
|
142
157
|
case 'status': return await runStatus({ ...ctx, args: parseStatus(rest) });
|
|
143
158
|
case 'refresh-clones': return await runRefreshClones({ ...ctx, args: parseRefreshClones(rest) });
|
|
159
|
+
case 'up-merge': return await runUpMerge({ ...ctx, args: parseUpMerge(rest) });
|
|
144
160
|
default:
|
|
145
161
|
err(`[library] unknown verb: ${verb}`);
|
|
146
162
|
log(USAGE);
|
|
@@ -195,6 +211,30 @@ function parseStatus(argv) {
|
|
|
195
211
|
});
|
|
196
212
|
return { json: values.json };
|
|
197
213
|
}
|
|
214
|
+
function parseUpMerge(argv) {
|
|
215
|
+
const { values } = parseArgs({
|
|
216
|
+
args: argv,
|
|
217
|
+
strict: true,
|
|
218
|
+
allowPositionals: false,
|
|
219
|
+
options: {
|
|
220
|
+
repo: { type: 'string', multiple: true },
|
|
221
|
+
pr: { type: 'string' },
|
|
222
|
+
'dry-run': { type: 'boolean', default: false },
|
|
223
|
+
once: { type: 'boolean', default: false },
|
|
224
|
+
verbose: { type: 'boolean', default: false },
|
|
225
|
+
json: { type: 'boolean', default: false },
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
const pr = values.pr ? parseInt(values.pr, 10) : undefined;
|
|
229
|
+
return {
|
|
230
|
+
repos: values.repo,
|
|
231
|
+
pr,
|
|
232
|
+
dryRun: values['dry-run'],
|
|
233
|
+
once: values.once,
|
|
234
|
+
verbose: values.verbose,
|
|
235
|
+
json: values.json,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
198
238
|
function parseRefreshClones(argv) {
|
|
199
239
|
const { values } = parseArgs({
|
|
200
240
|
args: argv,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Thin wrappers around the gh CLI, factored so tests can inject a runner
|
|
2
|
+
// and never spawn a real gh. Auth is handled by gh from the host's own
|
|
3
|
+
// login (TheFoot on smarthome01 for the scheduled path today).
|
|
4
|
+
//
|
|
5
|
+
// The runner signature is deliberately narrow: (cmd, args, { input }) ->
|
|
6
|
+
// Promise<{ code, stdout, stderr }>. Every wrapper takes an optional
|
|
7
|
+
// `runner` seam that defaults to the module-local `defaultRunner`, which
|
|
8
|
+
// is a plain spawn. Tests build a runner that inspects args and returns
|
|
9
|
+
// canned stdout/stderr, so no gh process is ever invoked.
|
|
10
|
+
//
|
|
11
|
+
// Ported from operator/extensions/tools/library-up-merger/src/github.mjs
|
|
12
|
+
// and factored for injection (w-2026-09-16-dave-014, d-2026-09-17-013).
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {(cmd: string, args: string[], opts?: { input?: string }) => Promise<{ code: number, stdout: string, stderr: string }>} Runner
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** @type {Runner} */
|
|
21
|
+
export function defaultRunner(cmd, args, { input } = {}) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
24
|
+
let stdout = '';
|
|
25
|
+
let stderr = '';
|
|
26
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
27
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
28
|
+
child.on('error', reject);
|
|
29
|
+
child.on('close', (code) => {
|
|
30
|
+
resolve({ code, stdout, stderr });
|
|
31
|
+
});
|
|
32
|
+
if (input != null) child.stdin.end(input);
|
|
33
|
+
else child.stdin.end();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function ghJson(args, { runner = defaultRunner, ghBin = 'gh' } = {}) {
|
|
38
|
+
const res = await runner(ghBin, args);
|
|
39
|
+
if (res.code !== 0) {
|
|
40
|
+
const err = new Error(`gh ${args.join(' ')} exited ${res.code}: ${res.stderr.trim()}`);
|
|
41
|
+
err.stderr = res.stderr;
|
|
42
|
+
err.stdout = res.stdout;
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
const text = res.stdout.trim();
|
|
46
|
+
if (!text) return null;
|
|
47
|
+
return JSON.parse(text);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function listOpenPrs(repoSlug, opts = {}) {
|
|
51
|
+
const data = await ghJson([
|
|
52
|
+
'pr', 'list', '--repo', repoSlug, '--state', 'open',
|
|
53
|
+
'--json', 'number,headRefName,author,title,url,isDraft',
|
|
54
|
+
'--limit', '100',
|
|
55
|
+
], opts);
|
|
56
|
+
return data || [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function listChangedFiles(repoSlug, number, opts = {}) {
|
|
60
|
+
const data = await ghJson([
|
|
61
|
+
'pr', 'view', String(number), '--repo', repoSlug,
|
|
62
|
+
'--json', 'files',
|
|
63
|
+
], opts);
|
|
64
|
+
return (data && Array.isArray(data.files) ? data.files : []).map((f) => f.path);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function mergePr(repoSlug, number, {
|
|
68
|
+
method = 'merge', deleteBranch = true, runner = defaultRunner, ghBin = 'gh',
|
|
69
|
+
} = {}) {
|
|
70
|
+
const args = ['pr', 'merge', String(number), '--repo', repoSlug, `--${method}`];
|
|
71
|
+
if (deleteBranch) args.push('--delete-branch');
|
|
72
|
+
const res = await runner(ghBin, args);
|
|
73
|
+
return {
|
|
74
|
+
ok: res.code === 0,
|
|
75
|
+
code: res.code,
|
|
76
|
+
stdout: res.stdout.trim(),
|
|
77
|
+
stderr: res.stderr.trim(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function commentOnPr(repoSlug, number, body, {
|
|
82
|
+
runner = defaultRunner, ghBin = 'gh',
|
|
83
|
+
} = {}) {
|
|
84
|
+
const res = await runner(
|
|
85
|
+
ghBin,
|
|
86
|
+
['pr', 'comment', String(number), '--repo', repoSlug, '--body-file', '-'],
|
|
87
|
+
{ input: body },
|
|
88
|
+
);
|
|
89
|
+
return {
|
|
90
|
+
ok: res.code === 0,
|
|
91
|
+
code: res.code,
|
|
92
|
+
stdout: res.stdout.trim(),
|
|
93
|
+
stderr: res.stderr.trim(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function listExistingComments(repoSlug, number, opts = {}) {
|
|
98
|
+
const data = await ghJson(
|
|
99
|
+
['pr', 'view', String(number), '--repo', repoSlug, '--json', 'comments'],
|
|
100
|
+
opts,
|
|
101
|
+
);
|
|
102
|
+
return (data && Array.isArray(data.comments) ? data.comments : []).map((c) => c.body || '');
|
|
103
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Scope-rule computation for library up-path PRs.
|
|
2
|
+
//
|
|
3
|
+
// A PR from head branch `<branchPrefix><device>` on library repo <repoSlug> may be merged
|
|
4
|
+
// when and only when EVERY changed file's repo-relative path falls under one of the
|
|
5
|
+
// device's owned up-paths for that library repo, computed from operator/registry/repos.json:
|
|
6
|
+
//
|
|
7
|
+
// For each mount M in repos[<library repo id>].mounts where M.upPath === true
|
|
8
|
+
// and M.upPathOwner === <device>:
|
|
9
|
+
// allowedPrefix = M.from + include, for each include in (M.upPathInclude || [])
|
|
10
|
+
// A file matches when its path === allowedPrefix (single-file include)
|
|
11
|
+
// or starts with allowedPrefix + "/" (directory include).
|
|
12
|
+
//
|
|
13
|
+
// A device with no owned up-paths on the repo, or a file matching none of its allowed
|
|
14
|
+
// prefixes, is a refusal. Refusals never merge.
|
|
15
|
+
//
|
|
16
|
+
// Ported verbatim (behaviour) from operator/extensions/tools/library-up-merger/src/scope.mjs
|
|
17
|
+
// on the promotion to core (w-2026-09-16-dave-014, d-2026-09-17-013). The HQ tool was the
|
|
18
|
+
// reference implementation; this module is the canonical home from 0.1.44 onward.
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} registry parsed operator/registry/repos.json
|
|
22
|
+
* @param {string} repoSlug e.g. "Stravica/stravica-library-wsd"
|
|
23
|
+
* @returns {string|null} matching registry key, or null
|
|
24
|
+
*/
|
|
25
|
+
export function repoIdForSlug(registry, repoSlug) {
|
|
26
|
+
const wanted = repoSlug.toLowerCase();
|
|
27
|
+
for (const [id, cfg] of Object.entries(registry.repos || {})) {
|
|
28
|
+
const remote = (cfg.gitRemote || '').toLowerCase();
|
|
29
|
+
// git@github.com:Owner/name.git -> Owner/name
|
|
30
|
+
const m = remote.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
|
|
31
|
+
if (m && m[1] === wanted) return id;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compute the sorted, de-duplicated list of allowed repo-relative path prefixes for a
|
|
38
|
+
* device on a library repo. Each entry is either an exact file path or a directory path
|
|
39
|
+
* (no trailing slash) - the matcher below appends "/" for directory matching.
|
|
40
|
+
*/
|
|
41
|
+
export function allowedPathsForDevice(registry, repoId, device) {
|
|
42
|
+
const repo = (registry.repos || {})[repoId];
|
|
43
|
+
if (!repo || !Array.isArray(repo.mounts)) return [];
|
|
44
|
+
const out = new Set();
|
|
45
|
+
for (const mount of repo.mounts) {
|
|
46
|
+
if (!mount || mount.upPath !== true) continue;
|
|
47
|
+
if (mount.upPathOwner !== device) continue;
|
|
48
|
+
const fromPrefix = String(mount.from || '').replace(/\/+$/, '');
|
|
49
|
+
const includes = Array.isArray(mount.upPathInclude) ? mount.upPathInclude : [];
|
|
50
|
+
for (const inc of includes) {
|
|
51
|
+
const clean = String(inc).replace(/\/+$/, '');
|
|
52
|
+
if (!clean) continue;
|
|
53
|
+
out.add(fromPrefix ? `${fromPrefix}/${clean}` : clean);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...out].sort();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Does `filePath` (repo-relative) fall under one of the `allowed` entries?
|
|
61
|
+
* An entry matches when the path equals it, or starts with `entry + "/"`.
|
|
62
|
+
*/
|
|
63
|
+
export function fileAllowed(filePath, allowed) {
|
|
64
|
+
const f = String(filePath || '').replace(/^\/+/, '');
|
|
65
|
+
for (const entry of allowed) {
|
|
66
|
+
if (f === entry) return true;
|
|
67
|
+
if (f.startsWith(`${entry}/`)) return true;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Classify a PR's file list against a device's allowed prefixes.
|
|
74
|
+
* @returns {{ ok: boolean, refused: string[], allowed: string[] }}
|
|
75
|
+
*/
|
|
76
|
+
export function classifyFiles(files, allowed) {
|
|
77
|
+
const refused = [];
|
|
78
|
+
const allowedFiles = [];
|
|
79
|
+
for (const f of files) {
|
|
80
|
+
if (fileAllowed(f, allowed)) allowedFiles.push(f);
|
|
81
|
+
else refused.push(f);
|
|
82
|
+
}
|
|
83
|
+
return { ok: refused.length === 0, refused, allowed: allowedFiles };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Parse a device out of a head-ref of the form `<branchPrefix><device>`.
|
|
88
|
+
* Returns null if the branch does not match.
|
|
89
|
+
*/
|
|
90
|
+
export function deviceFromBranch(branch, branchPrefix) {
|
|
91
|
+
if (!branch || !branch.startsWith(branchPrefix)) return null;
|
|
92
|
+
const rest = branch.slice(branchPrefix.length);
|
|
93
|
+
if (!rest || rest.includes('/')) return null;
|
|
94
|
+
return rest;
|
|
95
|
+
}
|