@mmerterden/multi-agent-pipeline 14.2.1 → 14.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +10 -6
- package/README.tr.md +143 -0
- package/docs/architecture.md +23 -8
- package/docs/ecosystem.md +237 -0
- package/install/_plugin-skills.mjs +16 -2
- package/install/codex.mjs +9 -4
- package/install/templates/copilot-instructions.md +12 -9
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/analysis/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/channels/SKILL.md +15 -4
- package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/dev/SKILL.md +10 -23
- package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +10 -2
- package/pipeline/commands/multi-agent/dev-local/SKILL.md +10 -24
- package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +10 -3
- package/pipeline/commands/multi-agent/jira/SKILL.md +13 -2
- package/pipeline/commands/multi-agent/language/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/local/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
- package/pipeline/commands/multi-agent/ship/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
- package/pipeline/lib/extract-conventions.sh +44 -15
- package/pipeline/lib/fetch-figma-annotations.sh +8 -1
- package/pipeline/lib/fetch-fortify.sh +23 -8
- package/pipeline/lib/figma-screenshot.sh +11 -1
- package/pipeline/lib/issue-fetcher.sh +76 -9
- package/pipeline/lib/md2confluence-v3.py +16 -2
- package/pipeline/lib/plan-todos.sh +5 -2
- package/pipeline/lib/post-pr-review.sh +8 -6
- package/pipeline/lib/shadow-git.sh +50 -9
- package/pipeline/lib/submodule-detector.sh +8 -1
- package/pipeline/multi-agent-refs/_input-parser.md +1 -1
- package/pipeline/multi-agent-refs/channels/confluence.md +3 -0
- package/pipeline/multi-agent-refs/channels/issue-comment.md +2 -2
- package/pipeline/multi-agent-refs/channels/jira.md +13 -2
- package/pipeline/multi-agent-refs/channels/pr-review-actions.md +1 -1
- package/pipeline/multi-agent-refs/channels/pr.md +20 -0
- package/pipeline/multi-agent-refs/channels/wiki.md +1 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
- package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
- package/pipeline/multi-agent-refs/generate-issue.md +2 -2
- package/pipeline/multi-agent-refs/issue-jira-triad.md +3 -3
- package/pipeline/multi-agent-refs/payload-contracts.md +67 -0
- package/pipeline/multi-agent-refs/phases/modes.md +20 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +8 -40
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +5 -3
- package/pipeline/multi-agent-refs/phases.md +6 -0
- package/pipeline/multi-agent-refs/rules.md +2 -0
- package/pipeline/schemas/prefs.schema.json +1 -1
- package/pipeline/scripts/audit-log-rotate.sh +10 -0
- package/pipeline/scripts/build-stack-plugins.mjs +8 -1
- package/pipeline/scripts/check-derived-drift.mjs +13 -1
- package/pipeline/scripts/diff-explain.mjs +41 -3
- package/pipeline/scripts/diff-risk-score.mjs +72 -8
- package/pipeline/scripts/gen-mode-dispatch.mjs +1 -1
- package/pipeline/scripts/learning-curve.mjs +8 -2
- package/pipeline/scripts/output-quality-check.sh +15 -4
- package/pipeline/scripts/phase-tracker.sh +21 -8
- package/pipeline/scripts/pre-commit-check.sh +69 -22
- package/pipeline/scripts/render-agent-log-cost.sh +8 -3
- package/pipeline/scripts/render-cost-summary.sh +42 -22
- package/pipeline/scripts/render-work-summary.sh +47 -13
- package/pipeline/scripts/review-scope.mjs +1 -1
- package/pipeline/scripts/run-aggregator.mjs +38 -14
- package/pipeline/scripts/smoke-schema-validation.sh +5 -1
- package/pipeline/scripts/test-gap-scan.mjs +45 -6
- package/pipeline/scripts/uninstall.mjs +39 -4
- package/pipeline/scripts/update-issue-progress.sh +12 -16
- package/pipeline/scripts/worktree-finalize.sh +23 -2
- package/pipeline/skills/.skills-index.json +11 -2
- package/pipeline/skills/shared/README.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +4 -17
- package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +8 -0
- package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +5 -18
- package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +8 -0
- package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-local/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-local-autopilot/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-ship/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +302 -0
- package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +144 -0
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +156 -0
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +108 -0
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +175 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +865 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +335 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +344 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +130 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +264 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +298 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +529 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +187 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +171 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +184 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +26 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +173 -0
- package/pipeline/skills/skills-index.md +3 -2
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Refresh the LEGACY label snapshot: fetch a legacy label map for every language
|
|
3
|
+
from your project's label endpoint and write it into the resources snapshot, so the
|
|
4
|
+
mapper resolves legacy values offline instead of a network round-trip per run. This
|
|
5
|
+
is the ONLY step that hits the network - run it manually / in CI to refresh, the same
|
|
6
|
+
way you refresh a design export.
|
|
7
|
+
|
|
8
|
+
Writes <out>/<lang>.json = flat {<key>: value} (keys verbatim, any prefix kept).
|
|
9
|
+
|
|
10
|
+
The endpoint is entirely project-specific, so nothing about it is baked in - you
|
|
11
|
+
supply the URL template and the headers it needs:
|
|
12
|
+
|
|
13
|
+
fetch-legacy-labels.py --out resources/Localization/Legacy --langs all \\
|
|
14
|
+
--endpoint 'https://labels.example.internal/{env}/labels/{lang}' \\
|
|
15
|
+
--headers-file .secrets/label-headers.json
|
|
16
|
+
|
|
17
|
+
Placeholders available in --endpoint: {lang} and {env}.
|
|
18
|
+
|
|
19
|
+
Prefer --headers-file (JSON object) over repeated --header for anything secret, so
|
|
20
|
+
tokens never land in shell history or CI logs. Response shape is discovered rather
|
|
21
|
+
than assumed: the largest flat {str: str} map anywhere in the JSON wins, which covers
|
|
22
|
+
both a bare map and one nested under an envelope.
|
|
23
|
+
"""
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import urllib.request
|
|
29
|
+
|
|
30
|
+
LANGS_ALL = ["en", "tr", "ar", "de", "es", "fr", "it", "ru"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def largest_str_map(obj):
|
|
34
|
+
best, stack = {}, [obj]
|
|
35
|
+
while stack:
|
|
36
|
+
cur = stack.pop()
|
|
37
|
+
if isinstance(cur, dict):
|
|
38
|
+
strs = {k: v for k, v in cur.items() if isinstance(v, str)}
|
|
39
|
+
if len(strs) > len(best):
|
|
40
|
+
best = strs
|
|
41
|
+
stack.extend(cur.values())
|
|
42
|
+
elif isinstance(cur, list):
|
|
43
|
+
stack.extend(cur)
|
|
44
|
+
return best
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_headers(a):
|
|
48
|
+
headers = {"Accept": "application/json"}
|
|
49
|
+
if a.headers_file:
|
|
50
|
+
with open(a.headers_file, encoding="utf-8") as fh:
|
|
51
|
+
loaded = json.load(fh)
|
|
52
|
+
if not isinstance(loaded, dict):
|
|
53
|
+
raise SystemExit(f"--headers-file must be a JSON object, got {type(loaded).__name__}")
|
|
54
|
+
headers.update({str(k): str(v) for k, v in loaded.items()})
|
|
55
|
+
for pair in a.header or []:
|
|
56
|
+
if "=" not in pair:
|
|
57
|
+
raise SystemExit(f"--header expects K=V, got {pair!r}")
|
|
58
|
+
k, v = pair.split("=", 1)
|
|
59
|
+
headers[k.strip()] = v.strip()
|
|
60
|
+
return headers
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_fail_statuses(values):
|
|
64
|
+
out = {}
|
|
65
|
+
for pair in values or []:
|
|
66
|
+
if "=" not in pair:
|
|
67
|
+
raise SystemExit(f"--fail-status expects CODE=message, got {pair!r}")
|
|
68
|
+
code, message = pair.split("=", 1)
|
|
69
|
+
out[code.strip()] = message.strip()
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def fetch(lang, headers, fail_statuses, a):
|
|
74
|
+
url = a.endpoint.format(env=a.env, lang=lang)
|
|
75
|
+
per_lang = dict(headers)
|
|
76
|
+
per_lang.setdefault("Accept-Language", lang)
|
|
77
|
+
req = urllib.request.Request(url, headers=per_lang)
|
|
78
|
+
with urllib.request.urlopen(req, timeout=a.timeout) as r:
|
|
79
|
+
data = json.loads(r.read().decode("utf-8"))
|
|
80
|
+
if isinstance(data, dict) and a.status_field in data:
|
|
81
|
+
code = str(data.get(a.status_field))
|
|
82
|
+
if code in fail_statuses:
|
|
83
|
+
raise RuntimeError(f"{url}: {fail_statuses[code]} (status {code})")
|
|
84
|
+
m = largest_str_map(data)
|
|
85
|
+
if not m:
|
|
86
|
+
raise RuntimeError(f"no label map found in the response for '{lang}' ({url})")
|
|
87
|
+
return m
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main():
|
|
91
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
92
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
93
|
+
ap.add_argument("--out", required=True, help="snapshot dir, e.g. resources/Localization/Legacy")
|
|
94
|
+
ap.add_argument("--endpoint", required=True,
|
|
95
|
+
help="URL template for the legacy label map; may contain {lang} and {env}")
|
|
96
|
+
ap.add_argument("--header", action="append",
|
|
97
|
+
help="extra request header as K=V (repeatable); avoid for secrets")
|
|
98
|
+
ap.add_argument("--headers-file", help="JSON object of request headers - use this for tokens")
|
|
99
|
+
ap.add_argument("--langs", default="all", help="'all' or a comma-separated list")
|
|
100
|
+
ap.add_argument("--env", default="", help="value substituted for {env} in --endpoint")
|
|
101
|
+
ap.add_argument("--status-field", default="statusCode",
|
|
102
|
+
help="response field carrying a provider status code")
|
|
103
|
+
ap.add_argument("--fail-status", action="append",
|
|
104
|
+
help="CODE=message for a provider error code that must abort (repeatable)")
|
|
105
|
+
ap.add_argument("--timeout", type=int, default=40)
|
|
106
|
+
a = ap.parse_args()
|
|
107
|
+
|
|
108
|
+
if "{lang}" not in a.endpoint:
|
|
109
|
+
print("warning: --endpoint has no {lang} placeholder; every language will hit the same URL",
|
|
110
|
+
file=sys.stderr)
|
|
111
|
+
|
|
112
|
+
headers = parse_headers(a)
|
|
113
|
+
fail_statuses = parse_fail_statuses(a.fail_status)
|
|
114
|
+
langs = LANGS_ALL if a.langs.strip() == "all" else [l.strip() for l in a.langs.split(",") if l.strip()]
|
|
115
|
+
os.makedirs(a.out, exist_ok=True)
|
|
116
|
+
total = 0
|
|
117
|
+
for lang in langs:
|
|
118
|
+
m = fetch(lang, headers, fail_statuses, a)
|
|
119
|
+
path = os.path.join(a.out, f"{lang}.json")
|
|
120
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
121
|
+
json.dump(m, fh, ensure_ascii=False, indent=0, sort_keys=True)
|
|
122
|
+
fh.write("\n")
|
|
123
|
+
total += len(m)
|
|
124
|
+
print(f" {lang}: {len(m)} labels → {path}")
|
|
125
|
+
print(f"legacy snapshot refreshed: {len(langs)} languages, ~{total // max(len(langs), 1)} keys each")
|
|
126
|
+
print("→ commit it like a design-export refresh")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
main()
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Publish (or UPDATE) one screen's localization map to Confluence - live, idempotent.
|
|
3
|
+
|
|
4
|
+
Targets a Confluence **Server/Data Center** instance with a **Bearer Personal Access
|
|
5
|
+
Token** (Server/DC auth - NOT Atlassian Cloud `user:token`). Nothing about your wiki is
|
|
6
|
+
baked in: pass `--base-url`, `--space` and `--parent`, or set `CONFLUENCE_BASE_URL`,
|
|
7
|
+
`CONFLUENCE_SPACE` and `CONFLUENCE_PARENT` once in the environment.
|
|
8
|
+
|
|
9
|
+
Idempotent by title under a parent page:
|
|
10
|
+
* search `space=<SPACE> and title="<title>"` (CQL);
|
|
11
|
+
* **exists → PUT** `/rest/api/content/<id>` with `version.number+1` → updates the
|
|
12
|
+
SAME page in place (re-running the mapper on a screen refreshes its page, never
|
|
13
|
+
duplicates it);
|
|
14
|
+
* **absent → POST** `/rest/api/content` under `--parent`.
|
|
15
|
+
Then attaches `screenshot.png` + the overlay PNG + (via `--keyshots <manifest>`) every
|
|
16
|
+
per-key screenpiece PNG from render-key-shots.py (the `<ac:image ri:filename=...>`
|
|
17
|
+
references in the body resolve to these), creating or updating each by filename.
|
|
18
|
+
`--attach <file>` (repeatable) attaches any additional document - e.g. the content
|
|
19
|
+
team's `<slug>.localization.xlsx` (build-spreadsheet.py) - and, for office/spreadsheet
|
|
20
|
+
files, appends a `view-file` macro section so it also renders inline on the page.
|
|
21
|
+
|
|
22
|
+
HTTP goes through **curl** on purpose: curl uses the system trust store, so it works
|
|
23
|
+
behind a corporate TLS proxy where Python's `urllib` fails cert verification. The token
|
|
24
|
+
is read from the keychain (item `CONFLUENCE_API_TOKEN`) and passed to curl via a
|
|
25
|
+
`chmod 600` `-K` config file - never on the argv / `ps`.
|
|
26
|
+
|
|
27
|
+
The skill asks the user for the target (space / parent / formats) and passes them here.
|
|
28
|
+
Read-only against the local artifacts; the only writes are to Confluence.
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
python3 publish-confluence.py --xml out/<slug>.confluence.xml --screen "<name>" \
|
|
32
|
+
--base-url https://wiki.example.com --space ENG --parent 12345 \
|
|
33
|
+
--screenshot out/screenshot.png --overlay out/<slug>.overlay.png
|
|
34
|
+
python3 publish-confluence.py --xml ... --title "Custom Title" # base/space/parent from env
|
|
35
|
+
python3 publish-confluence.py --xml ... --screen "<name>" --dry-run
|
|
36
|
+
"""
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import subprocess
|
|
41
|
+
import sys
|
|
42
|
+
import tempfile
|
|
43
|
+
import urllib.parse
|
|
44
|
+
|
|
45
|
+
DEFAULT_BASE = os.environ.get("CONFLUENCE_BASE_URL", "")
|
|
46
|
+
DEFAULT_SPACE = os.environ.get("CONFLUENCE_SPACE", "")
|
|
47
|
+
DEFAULT_PARENT = os.environ.get("CONFLUENCE_PARENT", "")
|
|
48
|
+
KEYCHAIN_ITEM = "CONFLUENCE_API_TOKEN"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_token(args):
|
|
52
|
+
if args.token:
|
|
53
|
+
return args.token
|
|
54
|
+
v = os.environ.get("CONFLUENCE_API_TOKEN")
|
|
55
|
+
if v:
|
|
56
|
+
return v.strip()
|
|
57
|
+
try:
|
|
58
|
+
r = subprocess.run(
|
|
59
|
+
["security", "find-generic-password", "-s", args.keychain_name, "-w"],
|
|
60
|
+
capture_output=True, text=True)
|
|
61
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
62
|
+
return r.stdout.strip()
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
sys.exit(f"No Confluence token - add keychain item '{args.keychain_name}', set "
|
|
66
|
+
"CONFLUENCE_API_TOKEN, or pass --token.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Cf:
|
|
70
|
+
"""Thin curl-backed Confluence Server REST client (Bearer auth via -K config)."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, base, token):
|
|
73
|
+
self.base = base.rstrip("/")
|
|
74
|
+
self._cfg = tempfile.NamedTemporaryFile("w", suffix=".curlrc", delete=False)
|
|
75
|
+
os.chmod(self._cfg.name, 0o600)
|
|
76
|
+
self._cfg.write(f'header = "Authorization: Bearer {token}"\n')
|
|
77
|
+
self._cfg.close()
|
|
78
|
+
|
|
79
|
+
def close(self):
|
|
80
|
+
try:
|
|
81
|
+
os.unlink(self._cfg.name)
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
def _run(self, args):
|
|
86
|
+
r = subprocess.run(["curl", "-sS", "-K", self._cfg.name, *args],
|
|
87
|
+
capture_output=True, text=True)
|
|
88
|
+
if r.returncode != 0:
|
|
89
|
+
sys.exit(f"curl failed: {r.stderr.strip()}")
|
|
90
|
+
return r.stdout
|
|
91
|
+
|
|
92
|
+
def _json(self, args):
|
|
93
|
+
out = self._run(args)
|
|
94
|
+
try:
|
|
95
|
+
data = json.loads(out)
|
|
96
|
+
except json.JSONDecodeError:
|
|
97
|
+
sys.exit(f"Confluence returned non-JSON (auth/endpoint?):\n{out[:400]}")
|
|
98
|
+
if isinstance(data, dict) and data.get("statusCode") and data.get("message"):
|
|
99
|
+
sys.exit(f"Confluence error {data['statusCode']}: {data['message']}")
|
|
100
|
+
return data
|
|
101
|
+
|
|
102
|
+
def find_by_title(self, space, title):
|
|
103
|
+
# DB-backed content query (NOT /content/search - CQL hits the async search
|
|
104
|
+
# index, which lags seconds behind a just-created page and breaks idempotency).
|
|
105
|
+
qs = urllib.parse.urlencode({"spaceKey": space, "title": title, "type": "page",
|
|
106
|
+
"expand": "version", "limit": "5"})
|
|
107
|
+
data = self._json([f"{self.base}/rest/api/content?{qs}"])
|
|
108
|
+
results = data.get("results") or []
|
|
109
|
+
return results[0] if results else None
|
|
110
|
+
|
|
111
|
+
def create(self, space, parent, title, storage):
|
|
112
|
+
body = {"type": "page", "title": title, "space": {"key": space},
|
|
113
|
+
"ancestors": [{"id": str(parent)}],
|
|
114
|
+
"body": {"storage": {"value": storage, "representation": "storage"}}}
|
|
115
|
+
return self._post_json("POST", f"{self.base}/rest/api/content", body)
|
|
116
|
+
|
|
117
|
+
def update(self, page_id, title, storage, new_version, space):
|
|
118
|
+
body = {"id": str(page_id), "type": "page", "title": title,
|
|
119
|
+
"space": {"key": space},
|
|
120
|
+
"version": {"number": new_version},
|
|
121
|
+
"body": {"storage": {"value": storage, "representation": "storage"}}}
|
|
122
|
+
return self._post_json("PUT", f"{self.base}/rest/api/content/{page_id}", body)
|
|
123
|
+
|
|
124
|
+
def _post_json(self, method, url, body):
|
|
125
|
+
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as bf:
|
|
126
|
+
json.dump(body, bf, ensure_ascii=False)
|
|
127
|
+
bpath = bf.name
|
|
128
|
+
try:
|
|
129
|
+
return self._json(["-X", method, "-H", "Content-Type: application/json",
|
|
130
|
+
"--data", f"@{bpath}", url])
|
|
131
|
+
finally:
|
|
132
|
+
os.unlink(bpath)
|
|
133
|
+
|
|
134
|
+
def existing_attachment(self, page_id, filename):
|
|
135
|
+
q = urllib.parse.quote(filename)
|
|
136
|
+
data = self._json([f"{self.base}/rest/api/content/{page_id}/child/attachment?filename={q}&limit=1"])
|
|
137
|
+
results = data.get("results") or []
|
|
138
|
+
return results[0] if results else None
|
|
139
|
+
|
|
140
|
+
def attach(self, page_id, path):
|
|
141
|
+
fn = os.path.basename(path)
|
|
142
|
+
existing = self.existing_attachment(page_id, fn)
|
|
143
|
+
if existing:
|
|
144
|
+
url = f"{self.base}/rest/api/content/{page_id}/child/attachment/{existing['id']}/data"
|
|
145
|
+
else:
|
|
146
|
+
url = f"{self.base}/rest/api/content/{page_id}/child/attachment"
|
|
147
|
+
return self._json(["-H", "X-Atlassian-Token: nocheck",
|
|
148
|
+
"-F", f"file=@{path}", "-X", "POST", url])
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
EMBED_EXTS = (".xlsx", ".xls", ".csv", ".docx", ".pdf")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def attachment_section(paths):
|
|
155
|
+
"""Storage-format section embedding a `view-file` macro for each office/spreadsheet
|
|
156
|
+
attachment, so the file renders inline on the page (not only in the drawer). Returns
|
|
157
|
+
"" when no path is an embeddable document."""
|
|
158
|
+
docs = [p for p in paths if os.path.splitext(p)[1].lower() in EMBED_EXTS]
|
|
159
|
+
if not docs:
|
|
160
|
+
return ""
|
|
161
|
+
macros = "".join(
|
|
162
|
+
'<p><ac:structured-macro ac:name="view-file">'
|
|
163
|
+
'<ac:parameter ac:name="name">'
|
|
164
|
+
f'<ri:attachment ri:filename="{os.path.basename(p)}"/>'
|
|
165
|
+
'</ac:parameter></ac:structured-macro></p>'
|
|
166
|
+
for p in docs
|
|
167
|
+
)
|
|
168
|
+
return "<h2>CMS Aktarım Dosyası</h2>" + macros
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main():
|
|
172
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
173
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
174
|
+
ap.add_argument("--xml", required=True, help="<slug>.confluence.xml (storage body)")
|
|
175
|
+
ap.add_argument("--screen", help="screen name (used in the default title)")
|
|
176
|
+
ap.add_argument("--title", help="page title (default: '<title-prefix> - <screen>')")
|
|
177
|
+
ap.add_argument("--title-prefix", default=os.environ.get("LOCALIZATION_PAGE_PREFIX", "Localization"),
|
|
178
|
+
help="prefix for the default title; set it to your wiki's wording "
|
|
179
|
+
"(env LOCALIZATION_PAGE_PREFIX)")
|
|
180
|
+
ap.add_argument("--screenshot", help="screenshot PNG to attach")
|
|
181
|
+
ap.add_argument("--overlay", help="overlay PNG to attach")
|
|
182
|
+
ap.add_argument("--keyshots", metavar="MANIFEST",
|
|
183
|
+
help="<slug>.keyshots.manifest.json (render-key-shots.py) - attaches every "
|
|
184
|
+
"per-key screenpiece PNG it lists, so the table's Ekran Görüntüsü "
|
|
185
|
+
"cells (<ri:attachment> refs) render on the page")
|
|
186
|
+
ap.add_argument("--attach", action="append", default=[], metavar="FILE",
|
|
187
|
+
help="extra file to attach (repeatable); e.g. <slug>.localization.xlsx. "
|
|
188
|
+
"Office/spreadsheet files also get a view-file macro on the page.")
|
|
189
|
+
ap.add_argument("--base", "--base-url", dest="base", default=DEFAULT_BASE,
|
|
190
|
+
help="Confluence base URL, e.g. https://wiki.example.com (env CONFLUENCE_BASE_URL)")
|
|
191
|
+
ap.add_argument("--space", default=DEFAULT_SPACE, help="space key (env CONFLUENCE_SPACE)")
|
|
192
|
+
ap.add_argument("--parent", default=DEFAULT_PARENT,
|
|
193
|
+
help="parent pageId the screen pages hang under (env CONFLUENCE_PARENT)")
|
|
194
|
+
ap.add_argument("--token", help="Confluence PAT (else keychain / env)")
|
|
195
|
+
ap.add_argument("--keychain-name", default=KEYCHAIN_ITEM)
|
|
196
|
+
ap.add_argument("--dry-run", action="store_true", help="print the plan, write nothing")
|
|
197
|
+
args = ap.parse_args()
|
|
198
|
+
|
|
199
|
+
title = args.title or (f"{args.title_prefix} - {args.screen}" if args.screen else None)
|
|
200
|
+
if not title:
|
|
201
|
+
ap.error("supply --title or --screen")
|
|
202
|
+
missing = [flag for flag, value in (("--base-url", args.base), ("--space", args.space),
|
|
203
|
+
("--parent", args.parent)) if not value]
|
|
204
|
+
if missing and not args.dry_run:
|
|
205
|
+
ap.error("no Confluence target configured - pass " + ", ".join(missing) +
|
|
206
|
+
" (or set CONFLUENCE_BASE_URL / CONFLUENCE_SPACE / CONFLUENCE_PARENT)")
|
|
207
|
+
if not os.path.isfile(args.xml):
|
|
208
|
+
ap.error(f"--xml not found: {args.xml}")
|
|
209
|
+
storage = open(args.xml, encoding="utf-8").read()
|
|
210
|
+
|
|
211
|
+
keyshot_files = []
|
|
212
|
+
if args.keyshots:
|
|
213
|
+
if not os.path.isfile(args.keyshots):
|
|
214
|
+
ap.error(f"--keyshots manifest not found: {args.keyshots}")
|
|
215
|
+
with open(args.keyshots, encoding="utf-8") as fh:
|
|
216
|
+
man = json.load(fh)
|
|
217
|
+
base = os.path.dirname(os.path.abspath(args.keyshots))
|
|
218
|
+
keyshot_files = [os.path.join(base, s["file"]) for s in man.get("shots", [])
|
|
219
|
+
if s.get("file", "").endswith(".png")]
|
|
220
|
+
|
|
221
|
+
attachments = [p for p in (args.screenshot, args.overlay, *keyshot_files, *args.attach) if p]
|
|
222
|
+
for p in attachments:
|
|
223
|
+
if not os.path.isfile(p):
|
|
224
|
+
ap.error(f"attachment not found: {p}")
|
|
225
|
+
|
|
226
|
+
# Embed a view-file macro for the spreadsheet/office attachments so they render on the page.
|
|
227
|
+
storage += attachment_section(args.attach)
|
|
228
|
+
|
|
229
|
+
if args.dry_run:
|
|
230
|
+
print(json.dumps({
|
|
231
|
+
"base": args.base, "space": args.space, "parent": args.parent,
|
|
232
|
+
"title": title, "xmlBytes": len(storage),
|
|
233
|
+
"attachments": [os.path.basename(p) for p in attachments],
|
|
234
|
+
"action": "search-then-create-or-update (idempotent by title)",
|
|
235
|
+
}, ensure_ascii=False, indent=2))
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
cf = Cf(args.base, get_token(args))
|
|
239
|
+
try:
|
|
240
|
+
existing = cf.find_by_title(args.space, title)
|
|
241
|
+
if existing:
|
|
242
|
+
cur = int(existing.get("version", {}).get("number", 1))
|
|
243
|
+
page = cf.update(existing["id"], title, storage, cur + 1, args.space)
|
|
244
|
+
action = f"updated (v{cur}→v{cur + 1})"
|
|
245
|
+
else:
|
|
246
|
+
page = cf.create(args.space, args.parent, title, storage)
|
|
247
|
+
action = "created"
|
|
248
|
+
page_id = page["id"]
|
|
249
|
+
|
|
250
|
+
attached = []
|
|
251
|
+
for p in attachments:
|
|
252
|
+
cf.attach(page_id, p)
|
|
253
|
+
attached.append(os.path.basename(p))
|
|
254
|
+
|
|
255
|
+
webui = (page.get("_links") or {}).get("webui", "")
|
|
256
|
+
url = f"{args.base}{webui}" if webui else f"{args.base}/pages/viewpage.action?pageId={page_id}"
|
|
257
|
+
print(json.dumps({"action": action, "id": page_id, "title": title,
|
|
258
|
+
"attachments": attached, "url": url}, ensure_ascii=False, indent=2))
|
|
259
|
+
finally:
|
|
260
|
+
cf.close()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
main()
|