@mmerterden/multi-agent-pipeline 20.26.0 → 20.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +1 -1
- package/docs/facts.json +3 -3
- package/install/catalog-history.json +1 -1
- package/manifest.json +42 -34
- package/package.json +5 -5
- package/pipeline/commands/multi-agent/channels/SKILL.md +19 -17
- package/pipeline/lib/issue-fetcher.sh +40 -2
- package/pipeline/lib/jira-remote-links.sh +99 -0
- package/pipeline/multi-agent-refs/analysis/intake.md +2 -0
- package/pipeline/multi-agent-refs/analysis/render.md +4 -2
- package/pipeline/multi-agent-refs/channels/jira.md +10 -8
- package/pipeline/multi-agent-refs/channels/pr.md +11 -8
- package/pipeline/multi-agent-refs/channels/test-run.md +52 -0
- package/pipeline/multi-agent-refs/conventions-defaults.md +2 -0
- package/pipeline/multi-agent-refs/features/analysis-issue-links.md +42 -0
- package/pipeline/multi-agent-refs/features/localization-reuse.md +25 -0
- package/pipeline/multi-agent-refs/features/url-enrichment.md +11 -3
- package/pipeline/multi-agent-refs/features/visual-evidence.md +35 -9
- package/pipeline/multi-agent-refs/generate-issue.md +9 -11
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +10 -6
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +2 -1
- package/pipeline/multi-agent-refs/phases.md +1 -1
- package/pipeline/multi-agent-refs/platform-parity.md +1 -1
- package/pipeline/multi-agent-refs/tracker-contract.md +10 -6
- package/pipeline/schemas/agent-state.schema.json +50 -0
- package/pipeline/schemas/prefs.schema.json +11 -0
- package/pipeline/schemas/unattended-policy.json +1 -0
- package/pipeline/scripts/_sibling-root.mjs +38 -0
- package/pipeline/scripts/gen-facts.mjs +5 -3
- package/pipeline/scripts/plan-remote-links.mjs +339 -0
- package/pipeline/scripts/render-flow-run.mjs +583 -0
- package/pipeline/scripts/render-jira-evidence.mjs +213 -0
- package/pipeline/skills/.skill-manifest.json +4 -4
- package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +8 -2
- package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
- package/pipeline/skills/shared/external/ios-localization/SKILL.md +5 -0
|
@@ -128,7 +128,7 @@ is not reported.
|
|
|
128
128
|
| 1 | Services | Does the counterpart screen call the same endpoints? An endpoint one side calls and the other does not is the finding - including a service that exists only on one platform. |
|
|
129
129
|
| 2 | Parameters | Same request fields, query parameters and headers, with the same optionality. A field sent by one side and omitted by the other is a finding even when both requests succeed. |
|
|
130
130
|
| 3 | Business rules | The conditions around the call and the response: validation, eligibility, retry, empty and error handling, feature-flag checks. A rule present on one side only is the finding. |
|
|
131
|
-
| 4 | Localization keys | The keys the changed screen uses on each side. Different key for the same string, a key one side has and the other does not, and a key that resolves to different copy. |
|
|
131
|
+
| 4 | Localization keys | The keys the changed screen uses on each side. Different key for the same string, a key one side has and the other does not, and a key that resolves to different copy. A key the change ADDS whose display text another key already holds on either side is a duplicate: the existing key is reused (`features/localization-reuse.md`). |
|
|
132
132
|
|
|
133
133
|
Nothing else. Layout, naming, architecture and idiom differ between the
|
|
134
134
|
platforms by design, and reporting them buries the four things that matter.
|
|
@@ -101,16 +101,16 @@ Register each phase:
|
|
|
101
101
|
|
|
102
102
|
```bash
|
|
103
103
|
bash $HOME/.claude/scripts/phase-tracker.sh add 0 "Init"
|
|
104
|
-
bash $HOME/.claude/scripts/phase-tracker.sh add 1 "
|
|
104
|
+
bash $HOME/.claude/scripts/phase-tracker.sh add 1 "Plan"
|
|
105
105
|
# ...
|
|
106
106
|
```
|
|
107
107
|
|
|
108
108
|
### Claude Code - also register a TaskList tile per phase
|
|
109
109
|
|
|
110
110
|
```text
|
|
111
|
-
For each
|
|
111
|
+
For each `TaskCreate` line `phase-tracker.sh tiles` prints, IN THAT ORDER:
|
|
112
112
|
TaskCreate({
|
|
113
|
-
subject: "Phase 0
|
|
113
|
+
subject: "Phase 0 Init",
|
|
114
114
|
description: "Repo discovery, branch, worktree, identity bind",
|
|
115
115
|
activeForm: "Initializing pipeline"
|
|
116
116
|
})
|
|
@@ -118,6 +118,8 @@ For each phase in the current mode, IN PHASE-NUMBER ORDER (0 → 1 → 2 → ...
|
|
|
118
118
|
bash phase-tracker.sh meta 0 tasklist_id "<taskId>"
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
+
**The subject is the tracker's line, verbatim.** `phase-tracker.sh tiles` prints each subject from the phase names the command registered (`0:Init 1:Plan 2:Dev 3:Review 4:Commit 5:Report` for a full run). Copy it as printed: no task-id prefix, no renamed, merged or annotated phase (`Analysis + Plan`, `Review + Verify`). What a phase does inside goes in `activeForm` or a sub-step tile, never in the phase name. A tile that disagrees with the tracker is a second, wrong progress record.
|
|
122
|
+
|
|
121
123
|
The `tasklist_id` meta field is persisted so that `:resume` can rebuild the TaskList from the state file in a fresh session.
|
|
122
124
|
|
|
123
125
|
#### The card is not the widget
|
|
@@ -310,13 +312,15 @@ fabricating numbers.
|
|
|
310
312
|
**Completion tile suffix (Claude Code).** The native widget cannot show
|
|
311
313
|
per-phase tokens, but subject edits after creation are safe (tile order is
|
|
312
314
|
fixed at TaskCreate time). So on phase completion, in addition to the
|
|
313
|
-
narration line,
|
|
315
|
+
narration line, set the subject to what `phase-tracker.sh subjects <N>` prints,
|
|
316
|
+
which is the tracker's own line with model, elapsed time and spend; the suffix is
|
|
317
|
+
the tracker's, never typed by hand:
|
|
314
318
|
|
|
315
319
|
```text
|
|
316
|
-
TaskUpdate({taskId: <phase_task>, subject: "Phase 2
|
|
320
|
+
TaskUpdate({taskId: <phase_task>, subject: "Phase 2 Dev - sonnet - 4m 12s - 35k tok - ~$0.26"})
|
|
317
321
|
```
|
|
318
322
|
|
|
319
|
-
|
|
323
|
+
A phase with no recorded tokens gets no token part from `subjects`; nothing is added by hand.
|
|
320
324
|
|
|
321
325
|
**Honesty note:** on Claude Code the orchestrator does not receive its own
|
|
322
326
|
usage metering, so per-phase counts are content-size estimates (chars/4 for
|
|
@@ -1863,6 +1863,17 @@
|
|
|
1863
1863
|
}
|
|
1864
1864
|
}
|
|
1865
1865
|
},
|
|
1866
|
+
"evidence": {
|
|
1867
|
+
"type": "object",
|
|
1868
|
+
"description": "Evidence gathered during the run for the report channels. Other evidence kinds live beside flowRuns.",
|
|
1869
|
+
"properties": {
|
|
1870
|
+
"flowRuns": {
|
|
1871
|
+
"type": "array",
|
|
1872
|
+
"description": "Flow-runner run ids (the runId in <RUN_ROOT>/history.jsonl) of the UI scenario runs that verify this task. Channels renders their Xray Test Execution in the 'Test Koşumu (Xray)' block; empty means channels discovers candidate runs instead.",
|
|
1873
|
+
"items": { "type": "string", "minLength": 1 }
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
},
|
|
1866
1877
|
"visualEvidence": {
|
|
1867
1878
|
"type": ["object", "null"],
|
|
1868
1879
|
"additionalProperties": false,
|
|
@@ -1987,6 +1998,45 @@
|
|
|
1987
1998
|
}
|
|
1988
1999
|
}
|
|
1989
2000
|
},
|
|
2001
|
+
"scenarios": {
|
|
2002
|
+
"type": "array",
|
|
2003
|
+
"description": "Per-scenario evidence: one screenshot and/or one recording for each test scenario a UI run covered, attached to the Jira issue. Rendered as one evidence line under that scenario by scripts/render-jira-evidence.mjs.",
|
|
2004
|
+
"items": {
|
|
2005
|
+
"type": "object",
|
|
2006
|
+
"additionalProperties": false,
|
|
2007
|
+
"required": ["id"],
|
|
2008
|
+
"properties": {
|
|
2009
|
+
"id": {
|
|
2010
|
+
"type": "string",
|
|
2011
|
+
"description": "Scenario id, as the test scenarios section names it."
|
|
2012
|
+
},
|
|
2013
|
+
"image": {
|
|
2014
|
+
"type": "object",
|
|
2015
|
+
"additionalProperties": true,
|
|
2016
|
+
"properties": {
|
|
2017
|
+
"file": { "type": "string" },
|
|
2018
|
+
"jiraFilename": { "type": "string" },
|
|
2019
|
+
"url": {
|
|
2020
|
+
"type": "string",
|
|
2021
|
+
"description": "Jira attachment content URL, as jira-attach.sh printed it."
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
},
|
|
2025
|
+
"video": {
|
|
2026
|
+
"type": "object",
|
|
2027
|
+
"additionalProperties": true,
|
|
2028
|
+
"properties": {
|
|
2029
|
+
"file": { "type": "string" },
|
|
2030
|
+
"jiraFilename": { "type": "string" },
|
|
2031
|
+
"url": {
|
|
2032
|
+
"type": "string",
|
|
2033
|
+
"description": "Jira attachment content URL, as jira-attach.sh printed it."
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
},
|
|
1990
2040
|
"gaps": {
|
|
1991
2041
|
"type": "array",
|
|
1992
2042
|
"description": "Every artefact that is required and absent, with its reason. A recorded reason satisfies the Phase 4 blocker; silence does not.",
|
|
@@ -829,6 +829,11 @@
|
|
|
829
829
|
"default": false,
|
|
830
830
|
"description": "v6.1.0+ - Per-phase token cost summary (phase name, tokens_in, tokens_out, est. USD). Source: phase-tracker.sh token tallies + optional OTel spans (MULTI_AGENT_OTEL_SPANS=1). Greyed out if tracker has no token data for the task. Opt-in - off by default so baseline PR body stays unchanged."
|
|
831
831
|
},
|
|
832
|
+
"testRun": {
|
|
833
|
+
"type": "boolean",
|
|
834
|
+
"default": false,
|
|
835
|
+
"description": "'Test Koşumu (Xray)' block: the Xray Test Execution of the task's UI scenario run, read from the flow runner's history.jsonl by render-flow-run.mjs. Ticked automatically when the run comes from --flow-run or state.evidence.flowRuns; greyed out when no run resolves."
|
|
836
|
+
},
|
|
832
837
|
"workSummary": {
|
|
833
838
|
"type": "boolean",
|
|
834
839
|
"default": false,
|
|
@@ -2231,6 +2236,12 @@
|
|
|
2231
2236
|
"default": true,
|
|
2232
2237
|
"description": "Off disables capture, upload and both render sections. The Phase 4 blocker goes with it."
|
|
2233
2238
|
},
|
|
2239
|
+
"jiraMedia": {
|
|
2240
|
+
"type": "string",
|
|
2241
|
+
"enum": ["links", "thumbnails"],
|
|
2242
|
+
"default": "links",
|
|
2243
|
+
"description": "How the Jira comment references screenshots: links (one evidence line per scenario, default) or thumbnails (small embedded images). Recordings are always links. Consumed by scripts/render-jira-evidence.mjs --mode."
|
|
2244
|
+
},
|
|
2234
2245
|
"maxAttachmentMb": {
|
|
2235
2246
|
"type": "integer",
|
|
2236
2247
|
"minimum": 1,
|
|
@@ -128,6 +128,7 @@
|
|
|
128
128
|
"update-issue-progress.sh": null,
|
|
129
129
|
"analysis-jira-write.sh": null,
|
|
130
130
|
"jira-attach.sh": null,
|
|
131
|
+
"jira-remote-links.sh": ["apply"],
|
|
131
132
|
"md2confluence-v3.py": ["create", "update"],
|
|
132
133
|
"multi-repo-pipeline.sh": ["push", "pr"],
|
|
133
134
|
"keychain-save.sh": null,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file _sibling-root.mjs - the directory that holds this repo's sibling
|
|
3
|
+
* checkouts (multi-agent-toolkit-mcp, multi-agent-plugins).
|
|
4
|
+
*
|
|
5
|
+
* Siblings sit beside the MAIN checkout. A linked worktree lives inside it, at
|
|
6
|
+
* `<main>/.worktrees/<name>`, so `<root>/..` from there is `.worktrees/` and no
|
|
7
|
+
* sibling is found. The main checkout is the directory that holds the shared
|
|
8
|
+
* git dir, so its parent is the answer for every worktree of the repo.
|
|
9
|
+
*
|
|
10
|
+
* Outside git, or when the shared git dir is not a `.git` directory (a bare
|
|
11
|
+
* repo, a submodule's `.git/modules/<name>`), it falls back to `<root>/..`.
|
|
12
|
+
*
|
|
13
|
+
* Usage as a command: `node _sibling-root.mjs <root>` prints the directory.
|
|
14
|
+
*
|
|
15
|
+
* Zero runtime dependencies (ADR-0004).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { basename, dirname, resolve } from "node:path";
|
|
20
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
21
|
+
|
|
22
|
+
/** @param {string} root */
|
|
23
|
+
export function siblingsParent(root) {
|
|
24
|
+
const fallback = resolve(root, "..");
|
|
25
|
+
const r = spawnSync(
|
|
26
|
+
"git",
|
|
27
|
+
["-C", root, "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
28
|
+
{ encoding: "utf8" },
|
|
29
|
+
);
|
|
30
|
+
if (r.status !== 0) return fallback;
|
|
31
|
+
const commonDir = r.stdout.trim();
|
|
32
|
+
if (basename(commonDir) !== ".git") return fallback;
|
|
33
|
+
return dirname(dirname(commonDir));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (invokedDirectly(import.meta.url)) {
|
|
37
|
+
process.stdout.write(`${siblingsParent(process.argv[2] || process.cwd())}\n`);
|
|
38
|
+
}
|
|
@@ -37,6 +37,7 @@ import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs";
|
|
|
37
37
|
import { join, dirname } from "node:path";
|
|
38
38
|
import { fileURLToPath } from "node:url";
|
|
39
39
|
import { spawnSync } from "node:child_process";
|
|
40
|
+
import { siblingsParent } from "./_sibling-root.mjs";
|
|
40
41
|
|
|
41
42
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
42
43
|
const ROOT = join(HERE, "..", "..");
|
|
@@ -94,9 +95,10 @@ try {
|
|
|
94
95
|
let toolCount = null;
|
|
95
96
|
let toolCategories = null;
|
|
96
97
|
let toolkitVersion = null;
|
|
97
|
-
// Sibling checkouts are looked up beside
|
|
98
|
-
// names another parent directory (a
|
|
99
|
-
|
|
98
|
+
// Sibling checkouts are looked up beside the main checkout, also from a worktree
|
|
99
|
+
// under .worktrees/; GEN_FACTS_SIBLINGS_DIR names another parent directory (a
|
|
100
|
+
// test points it at an empty one).
|
|
101
|
+
const siblingsDir = process.env.GEN_FACTS_SIBLINGS_DIR || siblingsParent(ROOT);
|
|
100
102
|
const toolkitDir = join(siblingsDir, "multi-agent-toolkit-mcp");
|
|
101
103
|
if (existsSync(join(toolkitDir, "package.json"))) {
|
|
102
104
|
try {
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* plan-remote-links.mjs - the Issue Links an analysis puts on its Jira issue.
|
|
4
|
+
*
|
|
5
|
+
* Jira's "Issue Links" panel holds remote links: a URL, a title and a group
|
|
6
|
+
* name (`relationship`). Links there survive any later edit of the description
|
|
7
|
+
* and can be read back through the API, so the sources an analysis used belong
|
|
8
|
+
* there as well as in the body.
|
|
9
|
+
*
|
|
10
|
+
* This file only PLANS. It reads the analysis spec and the issue's current
|
|
11
|
+
* remote links and prints what to write; `lib/jira-remote-links.sh` writes it.
|
|
12
|
+
* It never plans a delete.
|
|
13
|
+
*
|
|
14
|
+
* - One link per Figma frame, Confluence page, API contract and published
|
|
15
|
+
* analysis page, deduplicated by URL.
|
|
16
|
+
* - Every link carries a stable `globalId`. Jira treats a POST with a known
|
|
17
|
+
* globalId as an update, so a re-run rewrites its own links in place.
|
|
18
|
+
* Own ids start with `ma-`; a Confluence page linked through the
|
|
19
|
+
* application link uses Jira's native `appId=<id>&pageId=<n>` form.
|
|
20
|
+
* - A URL already linked under another globalId (by a person or another tool)
|
|
21
|
+
* is skipped, never duplicated or overwritten.
|
|
22
|
+
* - A Confluence page becomes a native "Wiki Page" only when the application
|
|
23
|
+
* id is known: passed in, or read from a Wiki Page link already on the
|
|
24
|
+
* issue for the same host. It is never guessed; without it the page is a
|
|
25
|
+
* plain link.
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
* node plan-remote-links.mjs --spec <analysis-spec.json | agent-state.json>
|
|
29
|
+
* [--existing <remotelink GET response>] [--app-id <host>=<id>] [--lang tr|en]
|
|
30
|
+
*
|
|
31
|
+
* Output: {"write": [<remotelink payload>], "skip": [{globalId, url, reason}]}
|
|
32
|
+
*/
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
35
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
36
|
+
|
|
37
|
+
const CONFLUENCE_APP = { type: "com.atlassian.confluence", name: "Confluence" };
|
|
38
|
+
|
|
39
|
+
const LABELS = {
|
|
40
|
+
en: {
|
|
41
|
+
figma: "Figma",
|
|
42
|
+
confluence: "Confluence",
|
|
43
|
+
api: "API Contract",
|
|
44
|
+
analysis: "Analysis",
|
|
45
|
+
wiki: "Wiki Page",
|
|
46
|
+
},
|
|
47
|
+
tr: {
|
|
48
|
+
figma: "Figma",
|
|
49
|
+
confluence: "Confluence",
|
|
50
|
+
api: "API Sözleşmesi",
|
|
51
|
+
analysis: "Analiz",
|
|
52
|
+
wiki: "Wiki Page",
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function normalizeUrl(url) {
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = new URL(String(url).trim());
|
|
60
|
+
} catch {
|
|
61
|
+
return String(url).trim();
|
|
62
|
+
}
|
|
63
|
+
parsed.hash = "";
|
|
64
|
+
parsed.hostname = parsed.hostname.toLowerCase();
|
|
65
|
+
const nodeId = parsed.searchParams.get("node-id");
|
|
66
|
+
if (/(^|\.)figma\.com$/.test(parsed.hostname)) {
|
|
67
|
+
// A Figma share link carries per-share parameters (t=, m=dev) and a renamable file
|
|
68
|
+
// slug; the frame is the file key plus the node id.
|
|
69
|
+
const { kind, fileKey } = figmaParts(parsed.toString());
|
|
70
|
+
if (fileKey) parsed.pathname = `/${kind}/${fileKey}`;
|
|
71
|
+
parsed.search = "";
|
|
72
|
+
if (nodeId) parsed.searchParams.set("node-id", nodeId.replace(/:/g, "-"));
|
|
73
|
+
} else {
|
|
74
|
+
parsed.searchParams.sort();
|
|
75
|
+
}
|
|
76
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/";
|
|
77
|
+
return parsed.toString();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hostOf(url) {
|
|
81
|
+
try {
|
|
82
|
+
return new URL(url).host.toLowerCase();
|
|
83
|
+
} catch {
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function shortHash(text) {
|
|
89
|
+
return createHash("sha1").update(text).digest("hex").slice(0, 12);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pageIdOf(page) {
|
|
93
|
+
if (page.pageId) return String(page.pageId);
|
|
94
|
+
const match = String(page.url || "").match(/[?&]pageId=(\d+)|\/pages\/(\d+)(?:\/|$)/);
|
|
95
|
+
return match ? match[1] || match[2] : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function figmaParts(url) {
|
|
99
|
+
try {
|
|
100
|
+
const parsed = new URL(url);
|
|
101
|
+
// A branch has its own key and its own frames; the branch key identifies them.
|
|
102
|
+
const key = parsed.pathname.match(
|
|
103
|
+
/^\/(design|file|proto|board)\/([^/]+)(?:\/branch\/([^/]+))?/,
|
|
104
|
+
);
|
|
105
|
+
return {
|
|
106
|
+
kind: key ? key[1] : null,
|
|
107
|
+
fileKey: key ? key[3] || key[2] : null,
|
|
108
|
+
nodeId: parsed.searchParams.get("node-id"),
|
|
109
|
+
};
|
|
110
|
+
} catch {
|
|
111
|
+
return { kind: null, fileKey: null, nodeId: null };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The application id of the Confluence link Jira already uses for pages on `host`, or null. */
|
|
116
|
+
export function confluenceAppId(existing, host) {
|
|
117
|
+
for (const link of existing || []) {
|
|
118
|
+
if (link?.application?.type !== CONFLUENCE_APP.type) continue;
|
|
119
|
+
const match = String(link.globalId || "").match(/^appId=([^&]+)&pageId=/);
|
|
120
|
+
if (match && hostOf(link.object?.url) === String(host).toLowerCase()) return match[1];
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function specOf(input) {
|
|
126
|
+
return input?.analysisSpec || input || {};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The links the spec asks for, in a stable order, one per URL. */
|
|
130
|
+
export function wantedLinks(input, { lang = "en" } = {}) {
|
|
131
|
+
const labels = LABELS[lang] || LABELS.en;
|
|
132
|
+
const spec = specOf(input);
|
|
133
|
+
const evidence = spec.evidence || {};
|
|
134
|
+
const wanted = [];
|
|
135
|
+
for (let frame of evidence.figma || []) {
|
|
136
|
+
if (!frame?.url) continue;
|
|
137
|
+
const fromUrl = figmaParts(frame.url);
|
|
138
|
+
frame = {
|
|
139
|
+
...frame,
|
|
140
|
+
fileKey: frame.fileKey || fromUrl.fileKey,
|
|
141
|
+
nodeId: frame.nodeId || fromUrl.nodeId,
|
|
142
|
+
};
|
|
143
|
+
const nodeId = frame.nodeId ? String(frame.nodeId).replace(/-/g, ":") : null;
|
|
144
|
+
const name = frame.frames?.find((f) => f?.name)?.name;
|
|
145
|
+
const title = nodeId
|
|
146
|
+
? name
|
|
147
|
+
? `${labels.figma} · ${name} (${nodeId})`
|
|
148
|
+
: `${labels.figma} · ${nodeId}`
|
|
149
|
+
: `${labels.figma} · ${name || frame.fileKey || "design"}`;
|
|
150
|
+
const globalId =
|
|
151
|
+
frame.fileKey && nodeId
|
|
152
|
+
? `ma-figma-${frame.fileKey}-${nodeId.replace(/:/g, "-")}`
|
|
153
|
+
: `ma-figma-${shortHash(normalizeUrl(frame.url))}`;
|
|
154
|
+
wanted.push({ kind: "figma", url: frame.url, title, relationship: labels.figma, globalId });
|
|
155
|
+
}
|
|
156
|
+
for (const page of evidence.confluence || []) {
|
|
157
|
+
if (!page?.url) continue;
|
|
158
|
+
const pageId = pageIdOf(page);
|
|
159
|
+
wanted.push({
|
|
160
|
+
kind: "confluence",
|
|
161
|
+
url: page.url,
|
|
162
|
+
title: page.title || `${labels.confluence} · ${pageId || hostOf(page.url)}`,
|
|
163
|
+
relationship: labels.confluence,
|
|
164
|
+
pageId,
|
|
165
|
+
globalId: `ma-confluence-${shortHash(normalizeUrl(page.url))}`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
for (const contract of evidence.swagger || []) {
|
|
169
|
+
if (!contract?.url) continue;
|
|
170
|
+
let leaf = hostOf(contract.url);
|
|
171
|
+
try {
|
|
172
|
+
leaf = new URL(contract.url).pathname.split("/").filter(Boolean).pop() || leaf;
|
|
173
|
+
} catch {
|
|
174
|
+
/* keep the host */
|
|
175
|
+
}
|
|
176
|
+
wanted.push({
|
|
177
|
+
kind: "api",
|
|
178
|
+
url: contract.url,
|
|
179
|
+
title: `API · ${leaf}`,
|
|
180
|
+
relationship: labels.api,
|
|
181
|
+
globalId: `ma-api-${shortHash(normalizeUrl(contract.url))}`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
for (const page of spec.outputs?.confluencePages || []) {
|
|
185
|
+
if (!page?.url) continue;
|
|
186
|
+
const pageId = pageIdOf(page);
|
|
187
|
+
wanted.push({
|
|
188
|
+
kind: "analysis",
|
|
189
|
+
url: page.url,
|
|
190
|
+
title: `${labels.analysis} · ${page.title || pageId || hostOf(page.url)}`,
|
|
191
|
+
relationship: labels.analysis,
|
|
192
|
+
pageId,
|
|
193
|
+
globalId: `ma-analysis-${shortHash(normalizeUrl(page.url))}`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
return wanted.filter((link) => {
|
|
198
|
+
const key = normalizeUrl(link.url);
|
|
199
|
+
if (seen.has(key)) return false;
|
|
200
|
+
seen.add(key);
|
|
201
|
+
return true;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function payloadFor(link, appIdFor, lang) {
|
|
206
|
+
const labels = LABELS[lang] || LABELS.en;
|
|
207
|
+
const isPage = link.kind === "confluence" || link.kind === "analysis";
|
|
208
|
+
const appId = isPage && link.pageId ? appIdFor(hostOf(link.url)) : null;
|
|
209
|
+
if (appId) {
|
|
210
|
+
return {
|
|
211
|
+
globalId: `appId=${appId}&pageId=${link.pageId}`,
|
|
212
|
+
application: { ...CONFLUENCE_APP },
|
|
213
|
+
relationship: labels.wiki,
|
|
214
|
+
object: { url: link.url, title: link.title },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
globalId: link.globalId,
|
|
219
|
+
relationship: link.relationship,
|
|
220
|
+
object: { url: link.url, title: link.title },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function sameLink(a, b) {
|
|
225
|
+
return (
|
|
226
|
+
a.relationship === b.relationship &&
|
|
227
|
+
normalizeUrl(a.object?.url) === normalizeUrl(b.object?.url) &&
|
|
228
|
+
a.object?.title === b.object?.title
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function planRemoteLinks({ spec, existing = [], appId = null, lang = "en" }) {
|
|
233
|
+
const current = Array.isArray(existing) ? existing : [];
|
|
234
|
+
const byGlobalId = new Map(current.filter((l) => l?.globalId).map((l) => [l.globalId, l]));
|
|
235
|
+
// An explicit id belongs to one Confluence host, like one read from an existing link.
|
|
236
|
+
const at = typeof appId === "string" ? appId.indexOf("=") : -1;
|
|
237
|
+
const explicit = at > 0 ? [appId.slice(0, at), appId.slice(at + 1)] : null;
|
|
238
|
+
const appIdFor = (host) =>
|
|
239
|
+
explicit && explicit[0].toLowerCase() === host ? explicit[1] : confluenceAppId(current, host);
|
|
240
|
+
const write = [];
|
|
241
|
+
const skip = [];
|
|
242
|
+
const planned = new Set();
|
|
243
|
+
for (const link of wantedLinks(spec, { lang })) {
|
|
244
|
+
let payload = payloadFor(link, appIdFor, lang);
|
|
245
|
+
const target = normalizeUrl(link.url);
|
|
246
|
+
// A link this tool wrote earlier under its plain id stays the link for that URL:
|
|
247
|
+
// a global id cannot change without a delete, and nothing here deletes.
|
|
248
|
+
const ownPlain = current.find(
|
|
249
|
+
(l) =>
|
|
250
|
+
String(l?.globalId || "").startsWith("ma-") &&
|
|
251
|
+
l.globalId !== payload.globalId &&
|
|
252
|
+
normalizeUrl(l.object?.url) === target,
|
|
253
|
+
);
|
|
254
|
+
if (ownPlain)
|
|
255
|
+
payload = {
|
|
256
|
+
globalId: ownPlain.globalId,
|
|
257
|
+
relationship: link.relationship,
|
|
258
|
+
object: { url: link.url, title: link.title },
|
|
259
|
+
};
|
|
260
|
+
if (planned.has(payload.globalId)) {
|
|
261
|
+
skip.push({ globalId: payload.globalId, url: link.url, reason: "duplicate-global-id" });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
planned.add(payload.globalId);
|
|
265
|
+
const own = byGlobalId.get(payload.globalId);
|
|
266
|
+
if (own) {
|
|
267
|
+
// Only an `ma-` link is ours to rewrite; a native Wiki Page link with the same id
|
|
268
|
+
// may be a person's, so it is left exactly as it is.
|
|
269
|
+
if (sameLink(own, payload))
|
|
270
|
+
skip.push({ globalId: payload.globalId, url: link.url, reason: "unchanged" });
|
|
271
|
+
else if (!String(own.globalId).startsWith("ma-"))
|
|
272
|
+
skip.push({ globalId: payload.globalId, url: link.url, reason: "already-linked" });
|
|
273
|
+
else write.push(payload);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const taken = current.some(
|
|
277
|
+
(l) => l?.globalId !== payload.globalId && normalizeUrl(l?.object?.url) === target,
|
|
278
|
+
);
|
|
279
|
+
if (taken) skip.push({ globalId: payload.globalId, url: link.url, reason: "already-linked" });
|
|
280
|
+
else write.push(payload);
|
|
281
|
+
}
|
|
282
|
+
return { write, skip };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function readJson(path, fallback) {
|
|
286
|
+
if (!path) return fallback;
|
|
287
|
+
if (!existsSync(path)) throw new Error(`no such file: ${path}`);
|
|
288
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function main(argv) {
|
|
292
|
+
const opts = {};
|
|
293
|
+
for (let i = 0; i < argv.length; i++) {
|
|
294
|
+
const arg = argv[i];
|
|
295
|
+
if (
|
|
296
|
+
!["--spec", "--existing", "--app-id", "--lang"].includes(arg) ||
|
|
297
|
+
argv[i + 1] === undefined
|
|
298
|
+
) {
|
|
299
|
+
process.stderr.write(`plan-remote-links: unknown or incomplete argument: ${arg}\n`);
|
|
300
|
+
return 2;
|
|
301
|
+
}
|
|
302
|
+
opts[arg.slice(2)] = argv[++i];
|
|
303
|
+
}
|
|
304
|
+
if (!opts.spec) {
|
|
305
|
+
process.stderr.write("plan-remote-links: --spec is required\n");
|
|
306
|
+
return 2;
|
|
307
|
+
}
|
|
308
|
+
let spec, existing;
|
|
309
|
+
try {
|
|
310
|
+
spec = readJson(opts.spec, {});
|
|
311
|
+
existing = readJson(opts.existing, []);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
process.stderr.write(`plan-remote-links: ${error.message}\n`);
|
|
314
|
+
return 2;
|
|
315
|
+
}
|
|
316
|
+
const appId = opts["app-id"];
|
|
317
|
+
if (appId !== undefined && !(appId.indexOf("=") > 0 && appId.indexOf("=") < appId.length - 1)) {
|
|
318
|
+
process.stderr.write("plan-remote-links: --app-id must be <host>=<id>\n");
|
|
319
|
+
return 2;
|
|
320
|
+
}
|
|
321
|
+
if (appId) {
|
|
322
|
+
const host = appId.slice(0, appId.indexOf("=")).toLowerCase();
|
|
323
|
+
const hosts = wantedLinks(spec)
|
|
324
|
+
.filter((link) => link.pageId)
|
|
325
|
+
.map((link) => hostOf(link.url));
|
|
326
|
+
if (!hosts.includes(host))
|
|
327
|
+
process.stderr.write(`plan-remote-links: --app-id host ${host} matches no Confluence page\n`);
|
|
328
|
+
}
|
|
329
|
+
const plan = planRemoteLinks({
|
|
330
|
+
spec,
|
|
331
|
+
existing,
|
|
332
|
+
appId: opts["app-id"] || null,
|
|
333
|
+
lang: opts.lang || "en",
|
|
334
|
+
});
|
|
335
|
+
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (invokedDirectly(import.meta.url)) process.exit(main(process.argv.slice(2)));
|