@mmerterden/multi-agent-pipeline 20.5.0 → 20.7.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 +36 -0
- package/LICENSE +10 -0
- package/docs/facts.json +5 -4
- package/install/_dev-only-files.mjs +1 -0
- package/manifest.json +84 -82
- package/package.json +4 -3
- package/pipeline/lib/confusables.json +100 -0
- package/pipeline/lib/normalize-text.mjs +48 -0
- package/pipeline/lib/outbound-gate.mjs +10 -1
- package/pipeline/lib/redact.mjs +4 -1
- package/pipeline/lib/vercel-deploy.sh +5 -7
- package/pipeline/multi-agent-refs/features/design-conformance.md +62 -64
- package/pipeline/scripts/_notices.mjs +11 -0
- package/pipeline/scripts/agent-guard.py +30 -1
- package/pipeline/scripts/gen-skills-index.mjs +1 -1
- package/pipeline/scripts/pre-commit-check.sh +59 -16
- package/pipeline/scripts/pre-push-check.sh +3 -3
- package/pipeline/scripts/run-ui-tests.sh +5 -5
- package/pipeline/scripts/website-deploy-commit.sh +3 -2
- package/pipeline/skills/.skill-manifest.json +39 -39
- package/pipeline/skills/shared/README.md +1 -1
- package/pipeline/skills/shared/external/agent-introspection-debugging/SKILL.md +1 -0
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +2 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +2 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +2 -0
- package/pipeline/skills/shared/external/backlog/BACKLOG.md +1 -1
- package/pipeline/skills/shared/external/backlog/SKILL.md +56 -33
- package/pipeline/skills/shared/external/ci-cd-pipelines/SKILL.md +1 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +2 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +3 -2
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +2 -0
- package/pipeline/skills/shared/external/council/SKILL.md +1 -0
- package/pipeline/skills/shared/external/css-modern/SKILL.md +1 -0
- package/pipeline/skills/shared/external/database-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/evidence-github/SKILL.md +2 -0
- package/pipeline/skills/shared/external/evidence-registry/SKILL.md +2 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +2 -0
- package/pipeline/skills/shared/external/html-semantic/SKILL.md +1 -0
- package/pipeline/skills/shared/external/humanizer/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-security/SKILL.md +2 -0
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +91 -283
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +119 -151
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +60 -90
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +119 -156
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +726 -787
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +253 -288
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +243 -304
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +88 -104
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +181 -235
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +198 -263
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +461 -466
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +145 -151
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +123 -141
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +146 -157
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +22 -19
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +156 -140
- package/pipeline/skills/shared/external/nextjs-app-router/SKILL.md +1 -0
- package/pipeline/skills/shared/external/play-store-review/SKILL.md +2 -0
- package/pipeline/skills/shared/external/python-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/react-best-practices/SKILL.md +1 -0
- package/pipeline/skills/shared/external/rest-api-design/SKILL.md +1 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +2 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +2 -0
- package/pipeline/skills/shared/external/search-first/SKILL.md +1 -0
- package/pipeline/skills/shared/external/signal-community/SKILL.md +2 -0
- package/pipeline/skills/shared/external/skill-creator/SKILL.md +80 -41
- package/pipeline/skills/shared/external/skill-creator/audit.md +63 -59
- package/pipeline/skills/shared/external/skill-creator/checklist.md +28 -20
- package/pipeline/skills/shared/external/skill-creator/examples.md +40 -40
- package/pipeline/skills/shared/external/skill-creator/label-check.md +48 -36
- package/pipeline/skills/shared/external/skill-creator/scripts/audit-panel.js +91 -100
- package/pipeline/skills/shared/external/skill-creator/template.md +51 -39
- package/pipeline/skills/shared/external/tailwind-css/SKILL.md +1 -0
- package/pipeline/skills/shared/external/testing-backend/SKILL.md +1 -0
- package/pipeline/skills/shared/external/typescript-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/vue-composition/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-accessibility/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-performance/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-testing/SKILL.md +1 -0
package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py
CHANGED
|
@@ -1,38 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Publish
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
2
|
+
"""Publish the reuse-map page to Confluence Server / Data Center, creating or updating it.
|
|
3
|
+
|
|
4
|
+
The page is located through the database-backed `content?spaceKey=&title=`
|
|
5
|
+
lookup rather than CQL, because the CQL index lags and would let a second copy
|
|
6
|
+
be created. An existing page is updated in place with the next version number;
|
|
7
|
+
otherwise a new one is created under the parent. Attachments are then added
|
|
8
|
+
or replaced. curl carries every request, which keeps the system trust store in
|
|
9
|
+
play, and the Bearer token sits in a curl config file with mode 600 instead of
|
|
10
|
+
on the command line.
|
|
36
11
|
"""
|
|
37
12
|
import argparse
|
|
38
13
|
import json
|
|
@@ -41,223 +16,194 @@ import subprocess
|
|
|
41
16
|
import sys
|
|
42
17
|
import tempfile
|
|
43
18
|
import urllib.parse
|
|
19
|
+
from xml.sax.saxutils import quoteattr
|
|
44
20
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
KEYCHAIN_ITEM = "CONFLUENCE_API_TOKEN"
|
|
21
|
+
EMBED_EXTENSIONS = {".xlsx", ".xls", ".csv", ".docx", ".pdf"}
|
|
22
|
+
EMBED_HEADING = "CMS Aktar\u0131m Dosyas\u0131"
|
|
23
|
+
CONTENT = "/rest/api/content"
|
|
49
24
|
|
|
50
25
|
|
|
51
|
-
|
|
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
|
-
|
|
26
|
+
class Client:
|
|
72
27
|
def __init__(self, base, token):
|
|
73
28
|
self.base = base.rstrip("/")
|
|
74
|
-
self.
|
|
75
|
-
os.
|
|
76
|
-
|
|
77
|
-
self.
|
|
29
|
+
self.tmp = tempfile.mkdtemp(prefix="confluence-")
|
|
30
|
+
self.config = os.path.join(self.tmp, "auth.curlrc")
|
|
31
|
+
quoted = token.replace("\\", "\\\\").replace('"', '\\"')
|
|
32
|
+
descriptor = os.open(self.config, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
33
|
+
with os.fdopen(descriptor, "w") as sink:
|
|
34
|
+
sink.write(f'header = "Authorization: Bearer {quoted}"\n')
|
|
78
35
|
|
|
79
36
|
def close(self):
|
|
37
|
+
for leftover in os.listdir(self.tmp):
|
|
38
|
+
os.remove(os.path.join(self.tmp, leftover))
|
|
39
|
+
os.rmdir(self.tmp)
|
|
40
|
+
|
|
41
|
+
def call(self, method, path, body=None, form_file=None):
|
|
42
|
+
argv = ["curl", "-sS", "-K", self.config, "-X", method]
|
|
43
|
+
if body is not None:
|
|
44
|
+
payload = os.path.join(self.tmp, "body.json")
|
|
45
|
+
with open(payload, "w", encoding="utf-8") as sink:
|
|
46
|
+
json.dump(body, sink, ensure_ascii=False)
|
|
47
|
+
argv += ["--data", "@" + payload, "-H", "Content-Type: application/json"]
|
|
48
|
+
if form_file is not None:
|
|
49
|
+
argv += ["-F", "file=@" + form_file, "-H", "X-Atlassian-Token: nocheck"]
|
|
50
|
+
argv.append(self.base + path)
|
|
51
|
+
done = subprocess.run(argv, text=True, capture_output=True)
|
|
52
|
+
if done.returncode != 0:
|
|
53
|
+
sys.exit(f"curl did not complete: {done.stderr.strip()}")
|
|
80
54
|
try:
|
|
81
|
-
|
|
82
|
-
except
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
""
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
55
|
+
reply = json.loads(done.stdout)
|
|
56
|
+
except ValueError:
|
|
57
|
+
sys.exit(f"Expected JSON from Confluence, got: {done.stdout[:400]}")
|
|
58
|
+
if isinstance(reply, dict) and "statusCode" in reply and "message" in reply:
|
|
59
|
+
sys.exit(f"Confluence answered {reply['statusCode']}: {reply['message']}")
|
|
60
|
+
return reply
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_token(explicit, keychain_name):
|
|
64
|
+
if explicit:
|
|
65
|
+
return explicit
|
|
66
|
+
if os.environ.get("CONFLUENCE_API_TOKEN"):
|
|
67
|
+
return os.environ["CONFLUENCE_API_TOKEN"]
|
|
68
|
+
try:
|
|
69
|
+
lookup = subprocess.run(
|
|
70
|
+
["security", "find-generic-password", "-s", keychain_name, "-w"],
|
|
71
|
+
capture_output=True, text=True, timeout=10,
|
|
72
|
+
)
|
|
73
|
+
except (OSError, subprocess.SubprocessError):
|
|
74
|
+
return None
|
|
75
|
+
secret = lookup.stdout.strip()
|
|
76
|
+
return secret if lookup.returncode == 0 and secret else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def view_file_macro(path):
|
|
80
|
+
target = f"<ri:attachment ri:filename={quoteattr(os.path.basename(path))}/>"
|
|
81
|
+
parameter = f'<ac:parameter ac:name="name">{target}</ac:parameter>'
|
|
82
|
+
return f'<p><ac:structured-macro ac:name="view-file">{parameter}</ac:structured-macro></p>'
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def embed_block(files):
|
|
86
|
+
return f"<h2>{EMBED_HEADING}</h2>" + "".join(map(view_file_macro, files))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def keyshot_files(manifest_path):
|
|
90
|
+
with open(manifest_path, encoding="utf-8") as stream:
|
|
91
|
+
manifest = json.load(stream)
|
|
92
|
+
folder = os.path.dirname(os.path.abspath(manifest_path))
|
|
93
|
+
names = [str(shot.get("file", "")) for shot in manifest.get("shots") or []]
|
|
94
|
+
return [os.path.join(folder, name) for name in names if name.endswith(".png")]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def page_fields(title, space):
|
|
98
|
+
return {"type": "page", "title": title, "space": {"key": space}}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_cli():
|
|
102
|
+
cli = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
103
|
+
cli.add_argument("--xml", required=True, help="page body in storage format (<slug>.confluence.xml)")
|
|
104
|
+
cli.add_argument("--screen", help="name of the screen; feeds the default title")
|
|
105
|
+
cli.add_argument("--title", help="explicit page title; otherwise '<title-prefix> - <screen>'")
|
|
106
|
+
cli.add_argument("--title-prefix", default=os.environ.get("LOCALIZATION_PAGE_PREFIX", "Localization"))
|
|
107
|
+
cli.add_argument("--screenshot", help="screen image to upload as an attachment")
|
|
108
|
+
cli.add_argument("--overlay", help="key-map overlay PNG to upload as an attachment")
|
|
109
|
+
cli.add_argument("--keyshots", metavar="MANIFEST", help="keyshots manifest; each .png it lists is uploaded")
|
|
110
|
+
cli.add_argument("--attach", metavar="FILE", action="append", default=[], help="additional file to upload; may be repeated")
|
|
111
|
+
cli.add_argument("--base", "--base-url", dest="base", default=os.environ.get("CONFLUENCE_BASE_URL"))
|
|
112
|
+
cli.add_argument("--space", default=os.environ.get("CONFLUENCE_SPACE"))
|
|
113
|
+
cli.add_argument("--parent", default=os.environ.get("CONFLUENCE_PARENT"), help="id of the parent page")
|
|
114
|
+
cli.add_argument("--token", help="Bearer PAT; CONFLUENCE_API_TOKEN or the keychain is safer since other processes can read argv")
|
|
115
|
+
cli.add_argument("--keychain-name", default="CONFLUENCE_API_TOKEN", help="keychain service that stores the PAT")
|
|
116
|
+
cli.add_argument("--dry-run", action="store_true", help="report the planned actions without touching Confluence")
|
|
117
|
+
return cli
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def check_inputs(cli, opts):
|
|
121
|
+
if not opts.title and not opts.screen:
|
|
122
|
+
cli.error("supply --title or --screen")
|
|
123
|
+
if not opts.dry_run:
|
|
124
|
+
target = {"--base": opts.base, "--space": opts.space, "--parent": opts.parent}
|
|
125
|
+
missing = [flag for flag, value in target.items() if not value]
|
|
126
|
+
if missing:
|
|
127
|
+
cli.error(f"missing {', '.join(missing)} (or set CONFLUENCE_BASE_URL / CONFLUENCE_SPACE / CONFLUENCE_PARENT)")
|
|
128
|
+
named = [("--xml", opts.xml), ("--keyshots", opts.keyshots), ("--screenshot", opts.screenshot), ("--overlay", opts.overlay)]
|
|
129
|
+
named += [("--attach", extra) for extra in opts.attach]
|
|
130
|
+
for label, path in named:
|
|
131
|
+
if path and not os.path.isfile(path):
|
|
132
|
+
cli.error(f"{label} file not found: {path}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def upsert_page(client, opts, title, body):
|
|
136
|
+
lookup = urllib.parse.urlencode({"spaceKey": opts.space, "title": title, "type": "page", "expand": "version", "limit": 5})
|
|
137
|
+
matches = client.call("GET", f"{CONTENT}?{lookup}").get("results") or []
|
|
138
|
+
storage = {"storage": {"value": body, "representation": "storage"}}
|
|
139
|
+
if not matches:
|
|
140
|
+
page = client.call("POST", CONTENT, {
|
|
141
|
+
**page_fields(title, opts.space), "ancestors": [{"id": opts.parent}], "body": storage,
|
|
142
|
+
})
|
|
143
|
+
return page, "created"
|
|
144
|
+
page_id = matches[0]["id"]
|
|
145
|
+
version = int((matches[0].get("version") or {}).get("number", 1))
|
|
146
|
+
page = client.call("PUT", f"{CONTENT}/{page_id}", {
|
|
147
|
+
"id": page_id, **page_fields(title, opts.space), "version": {"number": version + 1}, "body": storage,
|
|
148
|
+
})
|
|
149
|
+
return page, f"updated (v{version}->{version + 1})"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def upload(client, page_id, path):
|
|
153
|
+
name = os.path.basename(path)
|
|
154
|
+
attachments = f"{CONTENT}/{page_id}/child/attachment"
|
|
155
|
+
query = urllib.parse.urlencode({"filename": name, "limit": 1})
|
|
156
|
+
present = client.call("GET", f"{attachments}?{query}").get("results") or []
|
|
157
|
+
endpoint = f"{attachments}/{present[0]['id']}/data" if present else attachments
|
|
158
|
+
client.call("POST", endpoint, form_file=path)
|
|
159
|
+
return name
|
|
169
160
|
|
|
170
161
|
|
|
171
162
|
def main():
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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))
|
|
163
|
+
cli = build_cli()
|
|
164
|
+
opts = cli.parse_args()
|
|
165
|
+
check_inputs(cli, opts)
|
|
166
|
+
title = opts.title or f"{opts.title_prefix} - {opts.screen}"
|
|
167
|
+
|
|
168
|
+
files = [p for p in (opts.screenshot, opts.overlay) if p]
|
|
169
|
+
if opts.keyshots:
|
|
170
|
+
files += keyshot_files(opts.keyshots)
|
|
171
|
+
files += opts.attach
|
|
172
|
+
|
|
173
|
+
with open(opts.xml, encoding="utf-8") as stream:
|
|
174
|
+
body = stream.read()
|
|
175
|
+
embedded = [p for p in opts.attach if os.path.splitext(p)[1].lower() in EMBED_EXTENSIONS]
|
|
176
|
+
if embedded:
|
|
177
|
+
body += embed_block(embedded)
|
|
178
|
+
|
|
179
|
+
if opts.dry_run:
|
|
180
|
+
plan = {
|
|
181
|
+
"base": opts.base, "space": opts.space, "parent": opts.parent, "title": title,
|
|
182
|
+
"xmlBytes": len(body.encode("utf-8")), "attachments": files, "action": "dry-run",
|
|
183
|
+
}
|
|
184
|
+
print(json.dumps(plan, indent=2, ensure_ascii=False))
|
|
236
185
|
return
|
|
237
186
|
|
|
238
|
-
|
|
187
|
+
token = resolve_token(opts.token, opts.keychain_name)
|
|
188
|
+
if not token:
|
|
189
|
+
sys.exit(
|
|
190
|
+
"No Confluence token available. Put a Server/DC personal access token in the keychain "
|
|
191
|
+
f"(service '{opts.keychain_name}') or export CONFLUENCE_API_TOKEN."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
client = Client(opts.base, token)
|
|
239
195
|
try:
|
|
240
|
-
|
|
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"
|
|
196
|
+
page, action = upsert_page(client, opts, title, body)
|
|
248
197
|
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))
|
|
198
|
+
uploaded = [upload(client, page_id, path) for path in files]
|
|
259
199
|
finally:
|
|
260
|
-
|
|
200
|
+
client.close()
|
|
201
|
+
|
|
202
|
+
root = opts.base.rstrip("/")
|
|
203
|
+
link = (page.get("_links") or {}).get("webui")
|
|
204
|
+
url = root + link if link else f"{root}/pages/viewpage.action?pageId={page_id}"
|
|
205
|
+
report = {"action": action, "id": page_id, "title": title, "attachments": uploaded, "url": url}
|
|
206
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
261
207
|
|
|
262
208
|
|
|
263
209
|
if __name__ == "__main__":
|