@troylar/sightline 0.0.0-stage → 1.120.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +1882 -0
  2. package/CONTRIBUTING.md +67 -0
  3. package/LICENSE +21 -0
  4. package/README.md +153 -2
  5. package/THIRD-PARTY-NOTICES.md +121 -0
  6. package/bin/sightline.js +78 -0
  7. package/dist/sightline-app.py +406 -0
  8. package/dist/sightline-assistant.py +551 -0
  9. package/dist/sightline-jira-sync.py +2513 -0
  10. package/dist/sightline.html +14124 -0
  11. package/docs/assistant-workflow.md +472 -0
  12. package/docs/availability.md +54 -0
  13. package/docs/bundled-skills.md +46 -0
  14. package/docs/getting-started.md +99 -0
  15. package/docs/images/assistant-dark.png +0 -0
  16. package/docs/images/assistant-light.png +0 -0
  17. package/docs/images/bundled-skills-dark.png +0 -0
  18. package/docs/images/bundled-skills-light.png +0 -0
  19. package/docs/images/planning-decisions-dark.svg +46 -0
  20. package/docs/images/planning-decisions-light.svg +46 -0
  21. package/docs/images/planning-decisions-mobile-dark.svg +40 -0
  22. package/docs/images/planning-decisions-mobile-light.svg +40 -0
  23. package/docs/images/planning-story-dark.svg +32 -0
  24. package/docs/images/planning-story-light.svg +32 -0
  25. package/docs/images/planning-story-mobile-dark.svg +33 -0
  26. package/docs/images/planning-story-mobile-light.svg +33 -0
  27. package/docs/images/review-dark.png +0 -0
  28. package/docs/images/review-light.png +0 -0
  29. package/docs/images/roadmap-dark.png +0 -0
  30. package/docs/images/roadmap-light.png +0 -0
  31. package/docs/images/roadmap-map-dark.svg +53 -0
  32. package/docs/images/roadmap-map-light.svg +53 -0
  33. package/docs/images/roadmap-map-mobile-dark.svg +37 -0
  34. package/docs/images/roadmap-map-mobile-light.svg +37 -0
  35. package/docs/images/timeline-dark.png +0 -0
  36. package/docs/images/timeline-light.png +0 -0
  37. package/docs/images/today-dark.png +0 -0
  38. package/docs/images/today-light.png +0 -0
  39. package/docs/jira.md +66 -0
  40. package/docs/large-roadmaps.md +93 -0
  41. package/docs/onedrive.md +62 -0
  42. package/docs/performance-workspaces.md +39 -0
  43. package/docs/roadmap-planning-example.md +92 -0
  44. package/docs/roadmap.md +232 -0
  45. package/docs/screenshots.md +57 -0
  46. package/docs/skill-evaluation.md +71 -0
  47. package/docs/skills.md +85 -0
  48. package/docs/team-planning.md +66 -0
  49. package/docs/usability-test.md +215 -0
  50. package/docs/workspace-validation.md +152 -0
  51. package/docs/workspaces.md +105 -0
  52. package/examples/northstar/blank-plan.json +21 -0
  53. package/examples/northstar/brain-dump.txt +1 -0
  54. package/examples/northstar/jira/issues.json +1256 -0
  55. package/examples/northstar/jira/status.json +1542 -0
  56. package/examples/northstar/plan.json +341 -0
  57. package/examples/northstar/roadmap-session-amendments.json +42 -0
  58. package/examples/northstar/sightline-amendments.json +41 -0
  59. package/package.json +53 -4
  60. package/skills/sightline-edit-plan/SKILL.md +35 -0
  61. package/skills/sightline-meeting-to-plan/SKILL.md +34 -0
  62. package/skills/sightline-plan-roadmap/SKILL.md +34 -0
  63. package/skills/sightline-planning-guide/SKILL.md +29 -0
  64. package/skills/sightline-review-plan/SKILL.md +28 -0
  65. package/skills/sightline-sort-jira/SKILL.md +56 -0
  66. package/skills/sightline-status/SKILL.md +26 -0
@@ -0,0 +1,2513 @@
1
+ #!/usr/bin/env python3
2
+ """Sightline Jira sync (Jira Data Center).
3
+
4
+ Reads the Jira keys in a Sightline plan, asks Jira for each issue's status, assignee and due date,
5
+ and writes jira-status.json next to the plan. Sightline reads that file. The file never contains
6
+ your token.
7
+
8
+ With --jql or --filter it also runs a Jira search and writes the issues it finds to a separate file,
9
+ jira-issues.json, so Sightline can offer the ones that aren't in the plan yet. The search is yours: it
10
+ runs with your token, and anyone who can open jira-issues.json can read what it found. Use a search
11
+ your teammates are allowed to see.
12
+
13
+ Your personal access token (PAT) is kept in the operating system's credential store:
14
+ Windows Credential Manager on Windows, the login keychain on macOS. It is never written to disk
15
+ by this script, never printed, and never sent anywhere except your Jira server over HTTPS.
16
+
17
+ Standard library only (no pip installs), Python 3.9+.
18
+
19
+ python sightline-jira-sync.py setup # once: Jira address + token
20
+ python sightline-jira-sync.py sync PLAN.json # write jira-status.json beside it
21
+ python sightline-jira-sync.py sync PLAN.json --every 15 # keep syncing every 15 minutes
22
+ python sightline-jira-sync.py sync PLAN.json --filter 12345 # also write a saved filter's issues to jira-issues.json
23
+ python sightline-jira-sync.py sync PLAN.json --jql "project = ABC AND sprint in openSprints()"
24
+ python sightline-jira-sync.py sync PLAN.json --board 123 # also read the board's sprints
25
+ python sightline-jira-sync.py fields # find Epic Link, Sprint, Story Points (setup does this too)
26
+ python sightline-jira-sync.py inspect --board 123 --jql "project = ABC" # print statuses, resolutions, sprints, sample issues
27
+ python sightline-jira-sync.py sync PLAN.json --board 123 --filter 12345 --save # remember this plan's settings
28
+ python sightline-jira-sync.py watch # keep every saved plan in sync
29
+ python sightline-jira-sync.py install # your own copy, started when you sign in
30
+ python sightline-jira-sync.py forget # remove the stored token
31
+
32
+ Security decisions (documented per ASVS V1):
33
+ - HTTPS only; certificate verification always on, using the OS trust store (corporate CAs work).
34
+ - The token lives only in the OS credential store and in memory for the request; it is not logged.
35
+ - Read-only: the script only makes GET requests (/rest/api/2/search, /field to find your server's custom fields,
36
+ /myself to check the token, /rest/agile/1.0/board/N/sprint for --board). It never changes anything in Jira.
37
+ - Field ids (customfield_NNN) are found by field type, saved in the local config (no secrets), and checked
38
+ against ^customfield_[0-9]+$ before they are put in a request.
39
+ - The search comes from the command line of the person running the script, never from the shared plan:
40
+ a teammate who can edit the plan must not be able to point someone else's token at issues only they can see.
41
+ - A search returns at most --limit issues (default 500, at most 2000), fetched in pages.
42
+ - Jira responses are treated as untrusted: fields are type-checked, trimmed and length-capped
43
+ before they are written, and Sightline renders them as text, never as HTML.
44
+ - Output is written atomically (temp file, then rename) so Sightline never reads a half file.
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import argparse
49
+ import shutil
50
+ import datetime as _dt
51
+ import getpass
52
+ import hashlib
53
+ import json
54
+ import os
55
+ import re
56
+ import secrets
57
+ import ssl
58
+ import subprocess
59
+ import sys
60
+ import tempfile
61
+ import time
62
+ import urllib.error
63
+ import urllib.parse
64
+ import urllib.request
65
+
66
+ APP = "Sightline Jira"
67
+ CONFIG_DIR = os.path.join(os.environ.get("APPDATA") or os.path.expanduser("~/.config"), "Sightline")
68
+ CONFIG_PATH = os.path.join(CONFIG_DIR, "jira.json") # base URL only, no secrets
69
+ KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,19}-[1-9][0-9]{0,8}$")
70
+ BATCH = 50 # keys per JQL query
71
+ MAX_PLAN_BYTES = 5 * 1024 * 1024
72
+ # The Sightline page this copy matches. build.sh stamps it into the copy it publishes beside the page; None means this
73
+ # runs from source, where nothing is stamped and nothing is compared.
74
+ PAGE_VERSION = "1.120.2"
75
+ CONTEXT_FILE = "sightline-assistant-view.json"
76
+ PLAN_SCHEMA = 2 # the plan format this script reads; must equal SCHEMA in the page (a test checks)
77
+ STATUS_FORMAT = 1 # the formats this script writes; the page refuses files newer than it knows (a test checks)
78
+ ISSUES_FORMAT = 2
79
+ TIMEOUT = 30
80
+ FIELDS = "summary,status,assignee,duedate,updated,resolution,issuetype,priority,parent,labels,fixVersions,created,resolutiondate,description"
81
+ DESC_MAX = 4000 # a description, like a story in the plan
82
+ DESC_BUDGET = 1_000_000 # all descriptions in one file, so a plan of long descriptions can't outgrow the page's read limit
83
+ CUSTOM: dict[str, str] = {} # epicLink / sprint / storyPoints / parentLink -> customfield_NNN, from `fields`
84
+ FIELD_ID_RE = re.compile(r"^customfield_[0-9]{1,9}$")
85
+ # Jira Data Center gives these fields a different customfield_NNN on every server, but the field *type* is the same
86
+ # everywhere, so that's what we look for. Story Points has no type of its own: it's a number field found by its name.
87
+ FIELD_TYPES = {
88
+ "epicLink": "com.pyxis.greenhopper.jira:gh-epic-link",
89
+ "sprint": "com.pyxis.greenhopper.jira:gh-sprint",
90
+ "parentLink": "com.atlassian.jpo:jpo-custom-field-parent",
91
+ "epicName": "com.pyxis.greenhopper.jira:gh-epic-label", # only epics have one, whatever the admins named the type
92
+ }
93
+ POINT_NAMES = ("story points", "story point estimate")
94
+ PAGE = 100 # issues per page when running a search
95
+ FILTER_ID_RE = re.compile(r"^[1-9][0-9]{0,11}$")
96
+ MAX_JQL = 2000
97
+ MAX_ANCESTORS = 500 # parents above the synced issues (features, initiatives...)
98
+ ANCESTOR_LEVELS = 6
99
+ MAX_ISSUES = 2000 # issues in one issues.json, across all its searches
100
+ SEARCH_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 _.-]{0,29}$")
101
+ MAX_SEARCHES = 8
102
+
103
+
104
+ # ---------------------------------------------------------------- credential store
105
+ def _sq(v: str) -> str:
106
+ """Quote one argument for `security -i`: double quotes, with backslash and quote escaped."""
107
+ if "\n" in v or "\r" in v:
108
+ raise SystemExit("That value contains a line break, which the keychain can't store safely.")
109
+ return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
110
+
111
+
112
+ class CredentialStore:
113
+ """Stores one secret per Jira host in the OS credential store."""
114
+
115
+ def __init__(self, host: str):
116
+ self.target = f"{APP}: {host}"
117
+
118
+ def save(self, secret: str) -> None:
119
+ if sys.platform == "win32":
120
+ _win_cred_write(self.target, secret)
121
+ elif sys.platform == "darwin":
122
+ # `security -w <secret>` would put the token in the process arguments, which other local processes can read.
123
+ # `security -i` reads the command from stdin instead, so the token never appears in argv.
124
+ cmd = "add-generic-password -U -a {} -s {} -w {}\n".format(_sq(getpass.getuser()), _sq(self.target), _sq(secret))
125
+ subprocess.run(["security", "-i"], input=cmd, text=True, check=True, capture_output=True)
126
+ else:
127
+ raise SystemExit("This helper supports Windows and macOS credential stores only.")
128
+
129
+ def load(self) -> str | None:
130
+ if sys.platform == "win32":
131
+ return _win_cred_read(self.target)
132
+ if sys.platform == "darwin":
133
+ r = subprocess.run(["security", "find-generic-password", "-a", getpass.getuser(), "-s", self.target, "-w"],
134
+ capture_output=True, text=True)
135
+ return r.stdout.strip() or None if r.returncode == 0 else None
136
+ return None
137
+
138
+ def delete(self) -> bool:
139
+ if sys.platform == "win32":
140
+ return _win_cred_delete(self.target)
141
+ if sys.platform == "darwin":
142
+ return subprocess.run(["security", "delete-generic-password", "-a", getpass.getuser(), "-s", self.target],
143
+ capture_output=True).returncode == 0
144
+ return False
145
+
146
+
147
+ if sys.platform == "win32":
148
+ import ctypes
149
+ from ctypes import wintypes
150
+
151
+ class _CREDENTIAL(ctypes.Structure):
152
+ _fields_ = [("Flags", wintypes.DWORD), ("Type", wintypes.DWORD), ("TargetName", wintypes.LPWSTR),
153
+ ("Comment", wintypes.LPWSTR), ("LastWritten", wintypes.FILETIME), ("CredentialBlobSize", wintypes.DWORD),
154
+ ("CredentialBlob", ctypes.POINTER(ctypes.c_char)), ("Persist", wintypes.DWORD),
155
+ ("AttributeCount", wintypes.DWORD), ("Attributes", ctypes.c_void_p), ("TargetAlias", wintypes.LPWSTR),
156
+ ("UserName", wintypes.LPWSTR)]
157
+
158
+ _advapi = ctypes.WinDLL("advapi32", use_last_error=True)
159
+ _CRED_TYPE_GENERIC, _CRED_PERSIST_LOCAL_MACHINE = 1, 2
160
+
161
+ def _win_cred_write(target: str, secret: str) -> None:
162
+ blob = secret.encode("utf-16-le")
163
+ buf = ctypes.create_string_buffer(blob, len(blob))
164
+ cred = _CREDENTIAL(Type=_CRED_TYPE_GENERIC, TargetName=target, CredentialBlobSize=len(blob),
165
+ CredentialBlob=ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)),
166
+ Persist=_CRED_PERSIST_LOCAL_MACHINE, UserName=getpass.getuser())
167
+ if not _advapi.CredWriteW(ctypes.byref(cred), 0):
168
+ raise OSError(ctypes.get_last_error(), "Couldn't save the token to Windows Credential Manager")
169
+
170
+ def _win_cred_read(target: str) -> str | None:
171
+ p = ctypes.POINTER(_CREDENTIAL)()
172
+ if not _advapi.CredReadW(target, _CRED_TYPE_GENERIC, 0, ctypes.byref(p)):
173
+ return None
174
+ try:
175
+ c = p.contents
176
+ return ctypes.string_at(c.CredentialBlob, c.CredentialBlobSize).decode("utf-16-le")
177
+ finally:
178
+ _advapi.CredFree(p)
179
+
180
+ def _win_cred_delete(target: str) -> bool:
181
+ return bool(_advapi.CredDeleteW(target, _CRED_TYPE_GENERIC, 0))
182
+
183
+
184
+ # ---------------------------------------------------------------- config
185
+ def load_config() -> dict:
186
+ try:
187
+ with open(CONFIG_PATH, encoding="utf-8") as f:
188
+ cfg = json.load(f)
189
+ return cfg if isinstance(cfg, dict) else {}
190
+ except (OSError, ValueError):
191
+ return {}
192
+
193
+
194
+ def servers(cfg: dict) -> dict:
195
+ """{host: {"base", "fields"}}. An older single-server config ({"base", "fields"}) is moved in here."""
196
+ sv = cfg.get("servers") if isinstance(cfg.get("servers"), dict) else {}
197
+ if not sv and cfg.get("base"):
198
+ host = urllib.parse.urlparse(str(cfg["base"])).hostname or ""
199
+ if host:
200
+ sv = {host: {"base": cfg["base"], "fields": cfg.get("fields") if isinstance(cfg.get("fields"), dict) else {}}}
201
+ cfg["default"] = host
202
+ cfg["servers"] = sv
203
+ cfg.pop("base", None)
204
+ cfg.pop("fields", None)
205
+ return sv
206
+
207
+
208
+ def plan_key(plan: str) -> str:
209
+ return os.path.normcase(os.path.abspath(plan))
210
+
211
+
212
+ def saved_plan(cfg: dict, plan: str) -> dict:
213
+ sp = cfg.get("plans") if isinstance(cfg.get("plans"), dict) else {}
214
+ v = sp.get(plan_key(plan))
215
+ return v if isinstance(v, dict) else {}
216
+
217
+
218
+ def pick_server(cfg: dict, jira: str | None = None, plan: dict | None = None) -> dict:
219
+ """Which Jira: --jira, else the plan's saved one, else the only one, else the default."""
220
+ sv = servers(cfg)
221
+ if jira:
222
+ host = urllib.parse.urlparse(normalize_base(jira)).hostname
223
+ if host not in sv:
224
+ raise SystemExit(f"No Jira set up at {host}. Run: setup --jira {normalize_base(jira)}")
225
+ return sv[host]
226
+ want = (plan or {}).get("jira") or (next(iter(sv)) if len(sv) == 1 else cfg.get("default"))
227
+ if not want or want not in sv:
228
+ raise SystemExit("Run `setup` first." if not sv else f"Which Jira? Add --jira with one of: {', '.join('https://' + h for h in sv)}")
229
+ return sv[want]
230
+
231
+
232
+ def use_server(entry: dict) -> tuple[str, str]:
233
+ """(base, token) for a server, with its custom fields loaded (they differ from server to server)."""
234
+ base = normalize_base(entry["base"])
235
+ token = CredentialStore(urllib.parse.urlparse(base).hostname).load()
236
+ if not token:
237
+ raise SystemExit(f"No stored token for {urllib.parse.urlparse(base).hostname}. Run: setup --jira {base}")
238
+ saved = entry.get("fields") if isinstance(entry.get("fields"), dict) else {}
239
+ CUSTOM.clear()
240
+ CUSTOM.update({k: v for k, v in saved.items() if k in FIELD_LABELS and isinstance(v, str) and FIELD_ID_RE.match(v)})
241
+ return base, token
242
+
243
+
244
+ def save_config(cfg: dict) -> None:
245
+ os.makedirs(CONFIG_DIR, exist_ok=True)
246
+ with open(CONFIG_PATH, "w", encoding="utf-8") as f:
247
+ json.dump(cfg, f, indent=2)
248
+
249
+
250
+ def normalize_base(url: str) -> str:
251
+ url = url.strip().rstrip("/")
252
+ for suffix in ("/browse", "/secure/Dashboard.jspa"):
253
+ if url.lower().endswith(suffix.lower()):
254
+ url = url[: -len(suffix)]
255
+ p = urllib.parse.urlparse(url)
256
+ if p.scheme != "https" or not p.hostname:
257
+ raise SystemExit("The Jira address must start with https:// (for example https://jira.yourcompany.com).")
258
+ if p.username or p.password or p.query or p.fragment:
259
+ raise SystemExit("Enter just the Jira address, with no user name, ? or #.")
260
+ return url
261
+
262
+
263
+ # ---------------------------------------------------------------- plan + output
264
+ def plan_epics(plan: dict) -> list[str]:
265
+ """Epic keys linked to initiatives (bar.epics). Their child issues are counted, not listed."""
266
+ keys = set()
267
+ for bar in plan.get("bars", []) if isinstance(plan.get("bars"), list) else []:
268
+ for k in bar.get("epics", []) if isinstance(bar, dict) and isinstance(bar.get("epics"), list) else []:
269
+ k = str(k).strip().upper()
270
+ if KEY_RE.match(k):
271
+ keys.add(k)
272
+ return sorted(keys)
273
+
274
+
275
+ def parked_epics(plan: dict) -> set[str]:
276
+ """Epics whose initiative is parked: still counted, but their stories aren't listed (nobody is looking at them)."""
277
+ out = set()
278
+ for bar in plan.get("bars", []) if isinstance(plan.get("bars"), list) else []:
279
+ if isinstance(bar, dict) and bar.get("parked"):
280
+ for k in [*(bar.get("epics") if isinstance(bar.get("epics"), list) else []), bar.get("jira", "")]:
281
+ if KEY_RE.match(str(k).strip().upper()):
282
+ out.add(str(k).strip().upper())
283
+ return out
284
+
285
+
286
+ MAX_PLAN_KEYS = 2000 # Jira items one plan can have fetched, in all: placed ones first, then ones it only refers to
287
+
288
+
289
+ def check_plan_format(plan) -> None:
290
+ """Refuse a plan saved in a newer format than this script reads, rather than misread it and fetch the wrong issues."""
291
+ meta = plan.get("meta") if isinstance(plan, dict) else None
292
+ schema = meta.get("schema") if isinstance(meta, dict) else None
293
+ if isinstance(schema, int) and schema > PLAN_SCHEMA:
294
+ raise SystemExit(f"This plan was saved in plan format {schema}; this sync script reads format {PLAN_SCHEMA}. "
295
+ "Get the sync script that came with your copy of Sightline.")
296
+
297
+
298
+ def plan_keys(plan: dict, total: list | None = None) -> list[str]:
299
+ """The Jira keys the plan links, at most MAX_PLAN_KEYS; `total`, if given, gets how many it links before the cap."""
300
+ keys = set(plan_epics(plan))
301
+ for bar in plan.get("bars", []) if isinstance(plan.get("bars"), list) else []:
302
+ if not isinstance(bar, dict):
303
+ continue
304
+ for item in [bar, *[t for t in bar.get("tasks", []) if isinstance(t, dict)]]:
305
+ k = str(item.get("jira", "")).strip().upper()
306
+ if KEY_RE.match(k):
307
+ keys.add(k)
308
+ # Jira work linked to deliverables: fetched like placed work, so linked epics get their stories counted
309
+ for dv in plan.get("deliverables", []) if isinstance(plan.get("deliverables"), list) else []:
310
+ for k in dv.get("jira", []) if isinstance(dv, dict) and isinstance(dv.get("jira"), list) else []:
311
+ k = str(k).strip().upper()
312
+ if KEY_RE.match(k):
313
+ keys.add(k)
314
+ # Jira items the plan refers to without placing them: notes on them, dependencies on them, and tags kept by key
315
+ extra = set()
316
+ for bar in plan.get("bars", []) if isinstance(plan.get("bars"), list) else []:
317
+ for item in [bar, *[t for t in bar.get("tasks", []) if isinstance(t, dict)]] if isinstance(bar, dict) else []:
318
+ for k in item.get("jiraDeps", []) if isinstance(item.get("jiraDeps"), list) else []:
319
+ extra.add(str(k).strip().upper())
320
+ for n in plan.get("notes", []) if isinstance(plan.get("notes"), list) else []:
321
+ on = str(n.get("on", "")) if isinstance(n, dict) else ""
322
+ if on.startswith("jira:"):
323
+ extra.add(on[5:].strip().upper())
324
+ for x in plan.get("jiraItems", []) if isinstance(plan.get("jiraItems"), list) else []:
325
+ if isinstance(x, dict):
326
+ extra.add(str(x.get("id", "")).strip().upper())
327
+ for x in plan.get("proposals", []) if isinstance(plan.get("proposals"), list) else []:
328
+ if isinstance(x, dict):
329
+ extra.add(str(x.get("key", "")).strip().upper())
330
+ placed = sorted(k for k in keys if KEY_RE.match(k))
331
+ refs = sorted(k for k in extra if KEY_RE.match(k) and k not in keys)
332
+ if total is not None:
333
+ total.append(len(placed) + len(refs))
334
+ return sorted((placed + refs)[:MAX_PLAN_KEYS])
335
+
336
+
337
+ def fields_param() -> str:
338
+ return ",".join([FIELDS, *[v for v in CUSTOM.values() if FIELD_ID_RE.match(v)]])
339
+
340
+
341
+ def discover_fields(fields: list) -> dict:
342
+ """Pick our custom fields out of GET /rest/api/2/field. Returns {role: customfield_NNN} for the ones found."""
343
+ found, points = {}, []
344
+ for f in fields if isinstance(fields, list) else []:
345
+ if not isinstance(f, dict):
346
+ continue
347
+ fid, sch = clean(f.get("id"), 40), f.get("schema") if isinstance(f.get("schema"), dict) else {}
348
+ if not FIELD_ID_RE.match(fid):
349
+ continue
350
+ for role, typ in FIELD_TYPES.items():
351
+ if sch.get("custom") == typ and role not in found:
352
+ found[role] = fid
353
+ if clean(f.get("name"), 60).lower() in POINT_NAMES and sch.get("type") == "number":
354
+ points.append((POINT_NAMES.index(clean(f.get("name"), 60).lower()), fid))
355
+ if points:
356
+ found["storyPoints"] = sorted(points)[0][1] # "Story Points" before "Story point estimate"
357
+ return found
358
+
359
+
360
+ def _day(v) -> str:
361
+ """The date part of a Jira timestamp, or "" (Jira writes <null> for a sprint with no dates)."""
362
+ m = re.match(r"^(\d{4}-\d{2}-\d{2})", clean(v, 40))
363
+ return m.group(1) if m else ""
364
+
365
+
366
+ def _sprint_info(v) -> dict:
367
+ """The sprint an issue is in: the active one, else the latest. Jira DC sends objects or Sprint@...[k=v,...] strings."""
368
+ parsed = []
369
+ for x in v if isinstance(v, list) else []:
370
+ if isinstance(x, dict):
371
+ d = {"id": x.get("id"), "state": x.get("state"), "name": x.get("name"), "start": x.get("startDate"), "end": x.get("endDate")}
372
+ elif isinstance(x, str):
373
+ f = dict(re.findall(r"[,\[](id|state|name|startDate|endDate)=([^,\]]*)", x))
374
+ d = {"id": f.get("id"), "state": f.get("state"), "name": f.get("name"), "start": f.get("startDate"), "end": f.get("endDate")}
375
+ else:
376
+ continue
377
+ sid = int(d["id"]) if str(d["id"] or "").isdigit() and 0 < int(d["id"]) < 10**9 else 0
378
+ name = clean(d["name"], 80) if isinstance(d["name"], str) else ""
379
+ if name:
380
+ parsed.append({"id": sid, "state": clean(d["state"], 20).lower() if isinstance(d["state"], str) else "", "name": name,
381
+ "start": _day(d["start"]), "end": _day(d["end"])})
382
+ active = [p for p in parsed if p["state"] == "active"]
383
+ return (active or parsed or [{}])[-1]
384
+
385
+
386
+ def _sprint_of(v) -> str:
387
+ return _sprint_info(v).get("name", "")
388
+
389
+
390
+ def _key_of(v) -> str:
391
+ """A linked issue's key from Epic Link (a string), Parent Link ({data: {key}}), or parent ({key})."""
392
+ if isinstance(v, dict):
393
+ data = v.get("data") if isinstance(v.get("data"), dict) else {}
394
+ v = v.get("key") or data.get("key") or ""
395
+ k = clean(v, 40).upper()
396
+ return k if KEY_RE.match(k) else ""
397
+
398
+
399
+ def clean(v, n=200) -> str:
400
+ return v.strip()[:n] if isinstance(v, str) else ""
401
+
402
+
403
+ CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
404
+
405
+
406
+ def clean_desc(v) -> str:
407
+ """A description as plain text: Jira's wiki markup kept as written (the page converts it), control characters out."""
408
+ return CTRL_RE.sub("", v).strip()[:DESC_MAX] if isinstance(v, str) else ""
409
+
410
+
411
+ def own_keys(plan: dict) -> set:
412
+ """Keys an initiative or task links as itself: the only issues whose description the page shows."""
413
+ out = set()
414
+ for bar in plan.get("bars", []) if isinstance(plan.get("bars"), list) else []:
415
+ for item in [bar, *[t for t in bar.get("tasks", []) if isinstance(t, dict)]] if isinstance(bar, dict) else []:
416
+ k = str(item.get("jira", "")).strip().upper()
417
+ if KEY_RE.match(k):
418
+ out.add(k)
419
+ return out
420
+
421
+
422
+ def keep_descriptions(issues: dict, keys: set) -> None:
423
+ """Drop descriptions except for `keys`, and stop at DESC_BUDGET characters in all (key order, so it's stable)."""
424
+ room = DESC_BUDGET
425
+ for k in sorted(issues):
426
+ v = issues[k]
427
+ if not isinstance(v, dict) or "description" not in v:
428
+ continue
429
+ if k not in keys or len(v["description"]) > room:
430
+ v.pop("description")
431
+ continue
432
+ room -= len(v["description"])
433
+
434
+
435
+ USER_RE = re.compile(r"^[A-Za-z0-9._+-]{1,100}$")
436
+ EMAILISH = re.compile(r"[^\s@]+@[^\s@]+")
437
+
438
+
439
+ def display_name(who: dict, username: str) -> str:
440
+ """A person's name for the shared files. Some accounts show an email address as their name; that never goes out."""
441
+ n = clean(who.get("displayName"), 80)
442
+ return (username or "Jira user") if EMAILISH.search(n) else n
443
+
444
+
445
+ def user_id(who: dict) -> tuple[str, str]:
446
+ """(key, username) for linking Sightline people to Jira users. The key never changes on Data Center, so
447
+ Sightline matches on it. Some servers use email addresses as user names; an email is personal data and the
448
+ status files sit in a shared folder, so an email-like key is replaced by a one-way hash (still matchable)
449
+ and an email-like username is left out."""
450
+ key, name = clean(who.get("key"), 200), clean(who.get("name"), 200)
451
+ if key and not USER_RE.match(key):
452
+ key = "h-" + hashlib.sha256(key.lower().encode("utf-8")).hexdigest()[:24]
453
+ return key, name if USER_RE.match(name) else ""
454
+
455
+
456
+ def shape_issue(raw: dict, base: str) -> tuple[str, dict] | None:
457
+ """Keep only the fields Sightline shows, type-checked and length-capped (Jira data is untrusted)."""
458
+ key = clean(raw.get("key"), 40).upper()
459
+ if not KEY_RE.match(key):
460
+ return None
461
+ f = raw.get("fields") if isinstance(raw.get("fields"), dict) else {}
462
+ st = f.get("status") if isinstance(f.get("status"), dict) else {}
463
+ cat = st.get("statusCategory") if isinstance(st.get("statusCategory"), dict) else {}
464
+ who = f.get("assignee") if isinstance(f.get("assignee"), dict) else {}
465
+ due = clean(f.get("duedate"), 10)
466
+ akey, aname = user_id(who) if who else ("", "")
467
+ typ = f.get("issuetype") if isinstance(f.get("issuetype"), dict) else {}
468
+ pts = f.get(CUSTOM.get("storyPoints", ""))
469
+ pts = pts if isinstance(pts, (int, float)) and not isinstance(pts, bool) and 0 <= pts <= 1000 else None
470
+ pri = f.get("priority") if isinstance(f.get("priority"), dict) else {}
471
+ return key, {
472
+ "jid": clean(raw.get("id"), 20) if re.match(r"^[0-9]{1,18}$", clean(raw.get("id"), 20)) else "",
473
+ "summary": clean(f.get("summary"), 300),
474
+ **({"description": clean_desc(f.get("description"))} if "description" in f else {}), # "" is an empty one; absent, not fetched
475
+ "status": clean(st.get("name"), 60),
476
+ "category": {"new": "todo", "indeterminate": "inprogress", "done": "done"}.get(clean(cat.get("key"), 20), ""),
477
+ "assignee": display_name(who, aname) if who else "",
478
+ "assigneeKey": akey,
479
+ "assigneeUser": aname,
480
+ "due": due if re.match(r"^\d{4}-\d{2}-\d{2}$", due) else "",
481
+ "updated": clean(f.get("updated"), 40),
482
+ "created": clean(f.get("created"), 40),
483
+ "resolvedAt": clean(f.get("resolutiondate"), 40),
484
+ "resolved": bool(f.get("resolution")),
485
+ "resolution": clean((f.get("resolution") or {}).get("name"), 60) if isinstance(f.get("resolution"), dict) else "",
486
+ "type": clean(typ.get("name"), 40),
487
+ "epic": _key_of(f.get(CUSTOM.get("epicLink", ""))),
488
+ "parent": _key_of(f.get("parent")) or _key_of(f.get(CUSTOM.get("parentLink", ""))),
489
+ "sprint": (spi := _sprint_info(f.get(CUSTOM.get("sprint", "")))).get("name", ""),
490
+ "sprintId": spi.get("id", 0),
491
+ "sprintStart": spi.get("start", ""),
492
+ "sprintEnd": spi.get("end", ""),
493
+ "sprintState": spi.get("state", ""),
494
+ "points": pts,
495
+ "isEpic": bool(clean(f.get(CUSTOM.get("epicName", "")), 200)) or clean(typ.get("name"), 40).lower() == "epic",
496
+ "priority": clean(pri.get("name"), 40),
497
+ "labels": [clean(x, 60) for x in (f.get("labels") if isinstance(f.get("labels"), list) else []) if isinstance(x, str) and x.strip()][:20],
498
+ "releases": [{"id": clean(v.get("id"), 20), "name": clean(v.get("name"), 80)} for v in (f.get("fixVersions") if isinstance(f.get("fixVersions"), list) else [])
499
+ if isinstance(v, dict) and re.match(r"^[0-9]{1,18}$", clean(v.get("id"), 20))][:10],
500
+ "url": f"{base}/browse/{urllib.parse.quote(key)}",
501
+ }
502
+
503
+
504
+ def write_atomic(path: str, data: dict) -> None:
505
+ d = os.path.dirname(os.path.abspath(path))
506
+ fd, tmp = tempfile.mkstemp(prefix="." + os.path.splitext(os.path.basename(path))[0] + "-", suffix=".json", dir=d)
507
+ try:
508
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
509
+ json.dump(data, f, indent=2, ensure_ascii=False)
510
+ f.write("\n")
511
+ os.replace(tmp, path)
512
+ except BaseException:
513
+ try:
514
+ os.unlink(tmp)
515
+ except OSError:
516
+ pass
517
+ raise
518
+
519
+
520
+ # ---------------------------------------------------------------- Jira
521
+ class _RefuseRedirect(urllib.request.HTTPRedirectHandler):
522
+ """urllib copies request headers - including Authorization - onto a redirect, even to another host or to http://.
523
+ Jira's REST calls have no reason to redirect, so refuse instead of following."""
524
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
525
+ raise urllib.error.HTTPError(req.full_url, code, f"redirected to {newurl}", headers, fp)
526
+
527
+
528
+ def jira_opener():
529
+ return urllib.request.build_opener(_RefuseRedirect(), urllib.request.HTTPSHandler(context=ssl_context()))
530
+
531
+
532
+ def auth_request(url: str, token: str) -> urllib.request.Request:
533
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
534
+ req.add_unredirected_header("Authorization", f"Bearer {token}") # belt and braces: never copied onto a redirect
535
+ return req
536
+
537
+
538
+ def redirect_exit(e: urllib.error.HTTPError):
539
+ return SystemExit(f"Jira answered with a redirect ({e.code}), so the token was not sent on. "
540
+ "Check the Jira address in `setup` - use the exact https:// address you see in the browser.")
541
+
542
+
543
+ def ssl_context() -> ssl.SSLContext:
544
+ ctx = ssl.create_default_context() # verifies certificates; on Windows this reads the system (corporate) trust store
545
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
546
+ return ctx
547
+
548
+
549
+ def jira_search(base: str, token: str, keys: list[str], opener=None) -> dict:
550
+ """Returns {KEY: issue} for the keys Jira knows. Read-only GET requests."""
551
+ opener = opener or jira_opener()
552
+ found = {}
553
+ for i in range(0, len(keys), BATCH):
554
+ chunk = keys[i:i + BATCH]
555
+ jql = "key in (" + ",".join(chunk) + ")" # keys are validated against KEY_RE, so no JQL injection
556
+ q = urllib.parse.urlencode({"jql": jql, "fields": fields_param(), "maxResults": str(len(chunk)), "validateQuery": "false"})
557
+ req = auth_request(f"{base}/rest/api/2/search?{q}", token)
558
+ try:
559
+ with opener.open(req, timeout=TIMEOUT) as r:
560
+ body = json.loads(r.read(10 * 1024 * 1024).decode("utf-8"))
561
+ except urllib.error.HTTPError as e:
562
+ if 300 <= e.code < 400:
563
+ raise redirect_exit(e)
564
+ if e.code in (401, 403):
565
+ raise SystemExit("Jira refused the token (401/403). It may have expired: run `setup` again with a new personal access token.")
566
+ if e.code == 400: # an unknown key fails the whole query: retry one by one
567
+ for k in chunk:
568
+ found.update(jira_search(base, token, [k], opener) if len(chunk) > 1 else {})
569
+ continue
570
+ raise SystemExit(f"Jira answered with an error ({e.code}). Try again later.")
571
+ except urllib.error.URLError as e:
572
+ raise SystemExit(f"Couldn't reach Jira: {e.reason}")
573
+ for raw in body.get("issues", []) if isinstance(body, dict) else []:
574
+ shaped = shape_issue(raw, base) if isinstance(raw, dict) else None
575
+ if shaped:
576
+ found[shaped[0]] = shaped[1]
577
+ return found
578
+
579
+
580
+ def jql_for(jql: str | None, filter_id: str | None) -> str:
581
+ """The search to run, from --jql or --filter. Checked here, then Jira checks the JQL itself."""
582
+ if filter_id is not None:
583
+ f = str(filter_id).strip()
584
+ if not FILTER_ID_RE.match(f):
585
+ raise SystemExit("--filter takes the filter's number, the one in its address: .../issues/?filter=12345")
586
+ return f"filter = {f}"
587
+ q = " ".join(str(jql or "").split()) # one line: Jira ignores the line breaks anyway
588
+ if not q:
589
+ raise SystemExit("--jql needs a search, for example: --jql \"project = ABC AND resolution = Unresolved\"")
590
+ if len(q) > MAX_JQL:
591
+ raise SystemExit(f"That search is longer than {MAX_JQL} characters.")
592
+ return q
593
+
594
+
595
+ def parse_searches(values: list[str] | None) -> list[dict]:
596
+ """--search "Name=JQL", repeatable. Names are short labels for the Jira window; the JQL is checked like --jql."""
597
+ out = []
598
+ for v in values or []:
599
+ name, sep, q = str(v).partition("=")
600
+ name = " ".join(name.split())
601
+ if not sep or not SEARCH_NAME_RE.match(name):
602
+ raise SystemExit(f'--search takes "Name=JQL", with a short name (letters, digits, spaces; up to 30), for example "Epics=project = ABC". Got: {str(v)[:60]}')
603
+ if any(x["name"].lower() == name.lower() for x in out):
604
+ raise SystemExit(f"Two searches are both called {name}.")
605
+ out.append({"name": name, "jql": jql_for(q, None)})
606
+ if len(out) > MAX_SEARCHES:
607
+ raise SystemExit(f"At most {MAX_SEARCHES} searches per plan.")
608
+ return out
609
+
610
+
611
+ def jira_error_text(e: urllib.error.HTTPError) -> str:
612
+ """Jira's own reason for a 400, e.g. an unknown field in the JQL. Untrusted: one line, capped."""
613
+ try:
614
+ body = json.loads(e.read(64 * 1024).decode("utf-8"))
615
+ msgs = body.get("errorMessages") if isinstance(body, dict) else None
616
+ if isinstance(msgs, list) and msgs and isinstance(msgs[0], str):
617
+ return " ".join(msgs[0].split())[:300]
618
+ except (ValueError, OSError, UnicodeDecodeError):
619
+ pass
620
+ return ""
621
+
622
+
623
+ def jira_filter(base: str, token: str, jql: str, limit: int, opener=None) -> tuple[dict, list[str], int]:
624
+ """Runs one search. Returns ({KEY: issue}, keys in Jira's order, total Jira reported). Read-only GET requests."""
625
+ opener = opener or jira_opener()
626
+ found, order, total, start = {}, [], 0, 0
627
+ while start < limit:
628
+ q = urllib.parse.urlencode({"jql": jql, "fields": fields_param(), "startAt": str(start), "maxResults": str(min(PAGE, limit - start))})
629
+ req = auth_request(f"{base}/rest/api/2/search?{q}", token)
630
+ try:
631
+ with opener.open(req, timeout=TIMEOUT) as r:
632
+ body = json.loads(r.read(10 * 1024 * 1024).decode("utf-8"))
633
+ except urllib.error.HTTPError as e:
634
+ if 300 <= e.code < 400:
635
+ raise redirect_exit(e)
636
+ if e.code in (401, 403):
637
+ raise SystemExit("Jira refused the token (401/403). It may have expired: run `setup` again with a new personal access token.")
638
+ if e.code == 400:
639
+ why = jira_error_text(e)
640
+ raise SystemExit("Jira couldn't run that search" + (f": {why}" if why else ".") + " Check it in Jira's issue search first.")
641
+ raise SystemExit(f"Jira answered with an error ({e.code}). Try again later.")
642
+ except urllib.error.URLError as e:
643
+ raise SystemExit(f"Couldn't reach Jira: {e.reason}")
644
+ if not isinstance(body, dict):
645
+ break
646
+ total = body.get("total") if isinstance(body.get("total"), int) else total
647
+ raw = body.get("issues") if isinstance(body.get("issues"), list) else []
648
+ for r in raw:
649
+ shaped = shape_issue(r, base) if isinstance(r, dict) else None
650
+ if shaped and shaped[0] not in found:
651
+ found[shaped[0]] = shaped[1]
652
+ order.append(shaped[0])
653
+ start += len(raw)
654
+ if not raw or start >= total:
655
+ break
656
+ return found, order, total
657
+
658
+
659
+ LEAF_TYPES = re.compile(r"^(story|bug|task|sub-?task|defect|spike|improvement)$", re.I) # types that don't hold other issues
660
+ MAX_LEVELS = 3 # levels below an epic followed (feature, then its stories, ...)
661
+ ROLLUP_CAP = 5000 # child issues counted per sync, across all epics
662
+ STORY_CAP = 3000 # of those, how many are listed one by one (the page shows them)
663
+
664
+
665
+ def state_part(v: str) -> str:
666
+ """One part of a status|resolution|category key, with | and % escaped so a status named "Done | Verified" stays one part."""
667
+ return str(v or "").replace("%", "%25").replace("|", "%7C")
668
+
669
+
670
+ def rollup_jql(epics: list[str]) -> str:
671
+ """Children of these epics, by whichever links this server has. Keys are validated, ids checked: no injection."""
672
+ ks = ",".join(k for k in epics if KEY_RE.match(k))
673
+ parts = [f"cf[{CUSTOM[r][len('customfield_'):]}] in ({ks})" for r in ("epicLink", "parentLink") if FIELD_ID_RE.match(CUSTOM.get(r, ""))]
674
+ return " OR ".join(parts + [f"parent in ({ks})"])
675
+
676
+
677
+ ROLL_SUMS = ("total", "todo", "inprogress", "done", "unknown", "points", "pointsDone", "unestimated")
678
+
679
+
680
+ def jira_ancestors(base: str, token: str, known: dict) -> dict:
681
+ """The parents above what the sync already has, a few levels up: Parent Link or parent (a feature, an initiative,
682
+ an epic above an epic), so the page can nest its rows by epic to the top. Only what the tree needs is kept, and
683
+ they're written apart from the issues, so a parent never shows up as work to sort."""
684
+ got: dict = {}
685
+ ups = lambda vs: {p for v in vs for p in (v.get("parent"), v.get("epic")) if isinstance(p, str) and KEY_RE.match(p)}
686
+ frontier = ups(known.values())
687
+ for _ in range(ANCESTOR_LEVELS):
688
+ want = sorted(k for k in frontier if k not in known and k not in got)[:MAX_ANCESTORS - len(got)]
689
+ if not want:
690
+ break
691
+ found = jira_search(base, token, want)
692
+ for k, v in found.items():
693
+ got[k] = {f: v[f] for f in ("summary", "status", "category", "type", "isEpic", "parent", "epic") if f in v}
694
+ frontier = ups(found.values())
695
+ return got
696
+
697
+
698
+ def fold_nested(roll: dict, epic_issues: dict) -> None:
699
+ """An epic under another epic (a feature-level epic, say) adds its work to the top one, so every count rolls up to
700
+ the highest level. The nested epic keeps its own count too, for when it's placed on its own. Loops are ignored."""
701
+ def top_of(k: str) -> str:
702
+ cur, seen = k, {k}
703
+ for _ in range(8):
704
+ j = epic_issues.get(cur) or {}
705
+ up = next((p for p in (j.get("parent"), j.get("epic")) if p and p in roll), "")
706
+ if not up:
707
+ break
708
+ if up in seen:
709
+ return ""
710
+ seen.add(up)
711
+ cur = up
712
+ return "" if cur == k else cur
713
+ own = {k: json.loads(json.dumps(v)) for k, v in roll.items()}
714
+ for k, t in ((k, top_of(k)) for k in list(roll)):
715
+ if not t:
716
+ continue
717
+ a, b = roll[t], own[k]
718
+ for f in ROLL_SUMS:
719
+ a[f] += b[f]
720
+ for sk, (n, pts) in b["byState"].items():
721
+ if len(a["byState"]) < 200 or sk in a["byState"]:
722
+ x = a["byState"].setdefault(sk, [0, 0.0])
723
+ x[0] += n
724
+ x[1] += pts
725
+ if b["sprintStart"]:
726
+ a["sprintStart"] = min(a["sprintStart"] or b["sprintStart"], b["sprintStart"])
727
+ a["sprintEnd"] = max(a["sprintEnd"], b["sprintEnd"])
728
+ for lst in ("children", "sprintKids"):
729
+ if lst in a:
730
+ a[lst].extend(c for c in b.get("children", []) + b.get("sprintKids", []) if c not in a[lst])
731
+ elif b.get(lst) and lst == "sprintKids":
732
+ a[lst] = list(b[lst])
733
+ if b.get("childrenCut"):
734
+ a["childrenCut"] = True
735
+ a.setdefault("nested", []).append(k)
736
+
737
+
738
+ def jira_rollups(base: str, token: str, epics: list[str], opener=None, stories: dict | None = None, listed: set | None = None) -> tuple[dict, bool]:
739
+ """{EPIC: counts} for each epic's child issues: by status category, story points, and how many aren't estimated.
740
+ Only epics Jira already confirmed exist are asked about, since one unknown key fails the whole search.
741
+ With `stories`, the children of the `listed` epics (default: all) are also kept one by one (key -> issue), and each
742
+ of those epics gets `children`, so the page can show its stories; past STORY_CAP they're counted only."""
743
+ roll = {k: {"total": 0, "todo": 0, "inprogress": 0, "done": 0, "unknown": 0, "points": 0.0, "pointsDone": 0.0, "unestimated": 0,
744
+ "sprintStart": "", "sprintEnd": "", "byState": {}} for k in epics}
745
+ if stories is not None:
746
+ for k in epics:
747
+ if listed is None or k in listed:
748
+ roll[k]["children"], roll[k]["childrenCut"] = [], False
749
+ counted, truncated, owned = 0, False, set() # a story linked to two epics counts once, under the first
750
+ top, found_all = {}, {} # each counted issue -> the epic it rolls up to
751
+
752
+ def tally(epic: str, k: str, j: dict, sign: int = 1) -> None:
753
+ r = roll[epic]
754
+ r["total"] += sign
755
+ r[j["category"] or "unknown"] += sign # a status with no category isn't assumed to be To Do
756
+ sk = "|".join(state_part(x) for x in (j["status"], j.get("resolution", ""), j["category"])) # the page maps these with the team's status map
757
+ if len(r["byState"]) < 200 or sk in r["byState"]:
758
+ bs = r["byState"].setdefault(sk, [0, 0.0])
759
+ bs[0] += sign
760
+ bs[1] += sign * (j["points"] if isinstance(j.get("points"), (int, float)) else 0)
761
+ if bs[0] <= 0:
762
+ del r["byState"][sk]
763
+ if isinstance(j.get("points"), (int, float)):
764
+ r["points"] += sign * j["points"]
765
+ if j["category"] == "done":
766
+ r["pointsDone"] += sign * j["points"]
767
+ else:
768
+ r["unestimated"] += sign
769
+ if sign > 0 and j.get("sprintStart") and j.get("sprintEnd"):
770
+ r["sprintStart"] = min(r["sprintStart"] or j["sprintStart"], j["sprintStart"])
771
+ r["sprintEnd"] = max(r["sprintEnd"], j["sprintEnd"])
772
+
773
+ listed_epics = set(epics) # an epic that was asked about keeps its own count; fold_nested() rolls it up
774
+
775
+ def take(epic: str, k: str, j: dict) -> None:
776
+ owned.add(k)
777
+ top[k], found_all[k] = epic, j
778
+ r = roll[epic]
779
+ if "children" in r:
780
+ if len(stories) < STORY_CAP or k in stories:
781
+ stories.setdefault(k, j)
782
+ r["children"].append(k)
783
+ else:
784
+ r["childrenCut"] = True
785
+ elif j.get("sprintState") in ("active", "future") and (len(stories) < STORY_CAP or k in stories):
786
+ stories.setdefault(k, j) # an epic not on the board yet: its sprint stories, so the page can offer it
787
+ r.setdefault("sprintKids", []).append(k)
788
+ tally(epic, k, j)
789
+
790
+ for i in range(0, len(epics), BATCH):
791
+ chunk = epics[i:i + BATCH]
792
+ if counted >= ROLLUP_CAP:
793
+ truncated = True
794
+ break
795
+ found, order, total = jira_filter(base, token, rollup_jql(chunk), ROLLUP_CAP - counted, opener)
796
+ truncated = truncated or total > len(order)
797
+ want = set(chunk)
798
+ for k in order:
799
+ j = found[k]
800
+ epic = next((e for e in (j.get("epic"), j.get("parent")) if e in want), "")
801
+ if not epic or k in owned or k in listed_epics:
802
+ continue
803
+ take(epic, k, j)
804
+ counted += 1
805
+ # Levels in between (a feature under an epic, its stories under it): everything rolls up to the highest level, the
806
+ # epic. A child that turns out to hold others is a container, not work, so it leaves the counts and the story list.
807
+ def holds(j: dict) -> bool: # a known type that isn't a leaf (a feature, say) may hold issues
808
+ t = j.get("type", "")
809
+ return bool(t) and not LEAF_TYPES.match(t)
810
+
811
+ containers: set = set()
812
+ level = [k for k in top if holds(found_all[k])]
813
+ for _depth in range(MAX_LEVELS):
814
+ if not level or counted >= ROLLUP_CAP:
815
+ truncated = truncated or bool(level)
816
+ break
817
+ nxt, holders = [], set()
818
+ for i in range(0, len(level), BATCH):
819
+ chunk = level[i:i + BATCH]
820
+ if counted >= ROLLUP_CAP:
821
+ truncated = True
822
+ break
823
+ found, order, total = jira_filter(base, token, rollup_jql(chunk), ROLLUP_CAP - counted, opener)
824
+ truncated = truncated or total > len(order)
825
+ want = set(chunk)
826
+ for k in order:
827
+ j = found[k]
828
+ par = next((e for e in (j.get("epic"), j.get("parent")) if e in want), "")
829
+ if not par or k in listed_epics:
830
+ continue
831
+ holders.add(par) # it holds something, even when that something was already counted elsewhere
832
+ if k in owned:
833
+ continue
834
+ take(top[par], k, j)
835
+ counted += 1
836
+ if holds(j):
837
+ nxt.append(k)
838
+ for c in holders:
839
+ e = top[c]
840
+ tally(e, c, found_all[c], -1)
841
+ containers.add(c)
842
+ r = roll[e]
843
+ for lst in ("children", "sprintKids"):
844
+ if c in r.get(lst, []):
845
+ r[lst].remove(c)
846
+ stories.pop(c, None) # a container isn't a story: give its slot back
847
+ level = nxt
848
+ # Sprint span from the work that's left, now that containers are out.
849
+ for e, r in roll.items():
850
+ leaves = [found_all[k] for k, t in top.items() if t == e and k not in containers]
851
+ starts = [j["sprintStart"] for j in leaves if j.get("sprintStart") and j.get("sprintEnd")]
852
+ ends = [j["sprintEnd"] for j in leaves if j.get("sprintStart") and j.get("sprintEnd")]
853
+ r["sprintStart"], r["sprintEnd"] = (min(starts), max(ends)) if starts else ("", "")
854
+ return roll, truncated
855
+
856
+
857
+ BOARD_RE = re.compile(r"^[1-9][0-9]{0,9}$")
858
+ MAX_SPRINTS = 500
859
+
860
+
861
+ PROJECT_RE = re.compile(r"^([A-Z][A-Z0-9_]{0,19})-[0-9]+$")
862
+ MAX_RELEASE_PROJECTS, MAX_RELEASES = 10, 300
863
+ RELEASE_TTL = 6 * 3600 # seconds between rereading the projects' releases
864
+
865
+
866
+ def project_releases(base: str, token: str, keys, opener=None) -> tuple[list, list]:
867
+ """The releases (Jira "versions") of the projects these issues belong to, with Jira's own dates. Read-only GETs.
868
+ A project we can't read is skipped and named, never fatal: releases are extra, the sync's job is the issues."""
869
+ opener = opener or jira_opener()
870
+ counts: dict = {}
871
+ for k in keys:
872
+ m = PROJECT_RE.match(str(k))
873
+ if m:
874
+ counts[m.group(1)] = counts.get(m.group(1), 0) + 1
875
+ projects = sorted(counts, key=lambda p: (-counts[p], p))[:MAX_RELEASE_PROJECTS]
876
+ out, skipped = [], []
877
+ for proj in projects:
878
+ if len(out) >= MAX_RELEASES:
879
+ break
880
+ try:
881
+ with opener.open(auth_request(f"{base}/rest/api/2/project/{urllib.parse.quote(proj)}/versions", token), timeout=TIMEOUT) as r:
882
+ vals = json.loads(r.read(5 * 1024 * 1024).decode("utf-8"))
883
+ except urllib.error.HTTPError as e:
884
+ if 300 <= e.code < 400:
885
+ raise redirect_exit(e)
886
+ skipped.append(proj)
887
+ continue
888
+ except (urllib.error.URLError, ValueError):
889
+ skipped.append(proj)
890
+ continue
891
+ for v in vals if isinstance(vals, list) else []:
892
+ if len(out) >= MAX_RELEASES:
893
+ break
894
+ vid = clean(v.get("id"), 20) if isinstance(v, dict) else ""
895
+ if not re.match(r"^[0-9]{1,18}$", vid) or v.get("archived") is True:
896
+ continue
897
+ out.append({"id": vid, "name": clean(v.get("name"), 80), "project": proj, "date": _day(v.get("releaseDate")), "start": _day(v.get("startDate")),
898
+ "released": v.get("released") is True, "description": clean(v.get("description"), 300)})
899
+ return out, skipped
900
+
901
+
902
+ MAX_BURN_DAYS, MAX_BURN_SPRINTS = 40, 6
903
+ MAX_COMMENT_KEYS = 300
904
+ MAX_DEV, DEV_TTL, DEV_TTL_DONE, DEV_BUDGET = 200, 1800, 6 * 3600, 120 # issues whose pull requests are read per sync, how long a read is reused, and a time limit for the lot (seconds)
905
+
906
+
907
+ def _https(u: str) -> str:
908
+ u = clean(u, 400)
909
+ return u if re.match(r"^https://[^\s\"'<>]+$", u) else ""
910
+
911
+
912
+ PR_DRAFT_TITLE = re.compile(r"^\s*[\[(]?\s*(draft|wip)\s*[\])]?\s*[:\-]?\s", re.I) # "Draft: …", "[WIP] …"
913
+ PR_REPO_URL = re.compile(r"/projects/([^/]+)/repos/([^/]+)/pull-requests/", re.I)
914
+
915
+
916
+ def pr_repo(p: dict) -> str:
917
+ """The pull request's repository, "PROJECT/repo" where known: Jira's field when it sends one, else the target (or
918
+ source) repository, else read from the PR's Bitbucket address."""
919
+ def nm(x):
920
+ return clean(x.get("name") if isinstance(x, dict) else "", 100)
921
+ name = clean(p.get("repositoryName"), 100)
922
+ for side in ("destination", "source"):
923
+ if not name and isinstance(p.get(side), dict):
924
+ name = nm(p[side].get("repository"))
925
+ m = PR_REPO_URL.search(str(p.get("url") or ""))
926
+ if m:
927
+ proj, repo = urllib.parse.unquote(m.group(1)), urllib.parse.unquote(m.group(2))
928
+ return clean(f"{proj}/{name or repo}", 100)
929
+ return name
930
+
931
+
932
+ def dev_status(base: str, token: str, items: list, prev: dict, now: str, opener=None, done: set | None = None) -> dict:
933
+ """{KEY: {prs: [...], branches: n, at}} from Jira's development panel (Bitbucket linked to Jira), read with the
934
+ same token: no Bitbucket token needed. Only the active sprint's open work; a read under DEV_TTL old is reused. A
935
+ Jira without the panel answers 404 once and nothing more is asked."""
936
+ opener = opener or jira_opener()
937
+ out, t_now, t0 = {}, _dt.datetime.fromisoformat(now), time.monotonic()
938
+ for key, jid in items[:MAX_DEV]:
939
+ if time.monotonic() - t0 > DEV_BUDGET: # out of time: keep what we had, even if old; skip the rest (counted in devCover)
940
+ if isinstance(prev, dict) and isinstance(prev.get(key), dict):
941
+ out[key] = prev[key]
942
+ continue
943
+ old = prev.get(key) if isinstance(prev, dict) else None
944
+ if isinstance(old, dict) and old.get("at"):
945
+ try:
946
+ if (t_now - _dt.datetime.fromisoformat(old["at"])).total_seconds() < (DEV_TTL_DONE if key in (done or ()) else DEV_TTL): # a done issue's pull requests rarely change
947
+ out[key] = old
948
+ continue
949
+ except ValueError:
950
+ pass
951
+ q = urllib.parse.urlencode({"issueId": jid, "applicationType": "stash", "dataType": "pullrequest"})
952
+ try:
953
+ with opener.open(auth_request(f"{base}/rest/dev-status/latest/issue/detail?{q}", token), timeout=TIMEOUT) as r:
954
+ body = json.loads(r.read(5 * 1024 * 1024).decode("utf-8"))
955
+ except urllib.error.HTTPError as e:
956
+ if 300 <= e.code < 400:
957
+ raise redirect_exit(e)
958
+ if e.code in (400, 404):
959
+ break # this Jira has no development panel: stop asking
960
+ continue
961
+ except (urllib.error.URLError, ValueError):
962
+ continue
963
+ prs, branches = [], 0
964
+ for d in (body.get("detail") or []) if isinstance(body, dict) else []:
965
+ if not isinstance(d, dict):
966
+ continue
967
+ branches += len(d.get("branches") or []) if isinstance(d.get("branches"), list) else 0
968
+ for p in d.get("pullRequests") or []:
969
+ if not isinstance(p, dict) or len(prs) >= 20:
970
+ continue
971
+ revs = [x for x in (p.get("reviewers") or []) if isinstance(x, dict)]
972
+ st = clean(p.get("status"), 20).upper()
973
+ draft = st == "DRAFT" or p.get("draft") is True or p.get("isDraft") is True or bool(PR_DRAFT_TITLE.match(str(p.get("name") or "")))
974
+ prs.append({"title": clean(p.get("name"), 200), "url": _https(p.get("url")), "status": "OPEN" if st == "DRAFT" else st, **({"draft": True} if draft else {}),
975
+ "author": clean((p.get("author") or {}).get("name") if isinstance(p.get("author"), dict) else "", 80),
976
+ "repo": pr_repo(p), "updated": clean(p.get("lastUpdate"), 40),
977
+ "reviewers": len(revs), "approved": sum(1 for x in revs if x.get("approved") is True)})
978
+ out[key] = {"prs": prs, "branches": branches, "at": now}
979
+ return out
980
+
981
+
982
+ def last_comments(base: str, token: str, keys: list, opener=None) -> dict:
983
+ """{KEY: day of its latest comment}, for the issues shown as cards (this and coming sprints). Only the comment
984
+ field is asked for, in batches, so the ordinary searches stay light. Any failure just leaves dates out."""
985
+ opener = opener or jira_opener()
986
+ out: dict = {}
987
+ ks = [k for k in keys if KEY_RE.match(str(k))][:MAX_COMMENT_KEYS]
988
+ for i in range(0, len(ks), BATCH):
989
+ chunk = ks[i:i + BATCH]
990
+ q = urllib.parse.urlencode({"jql": f"key in ({','.join(chunk)})", "fields": "comment", "maxResults": str(len(chunk))})
991
+ try:
992
+ with opener.open(auth_request(f"{base}/rest/api/2/search?{q}", token), timeout=TIMEOUT) as r:
993
+ body = json.loads(r.read(10 * 1024 * 1024).decode("utf-8"))
994
+ except urllib.error.HTTPError as e:
995
+ if 300 <= e.code < 400:
996
+ raise redirect_exit(e)
997
+ continue
998
+ except (urllib.error.URLError, ValueError):
999
+ continue
1000
+ for it in body.get("issues") or [] if isinstance(body, dict) else []:
1001
+ k = clean(it.get("key"), 40).upper() if isinstance(it, dict) else ""
1002
+ cm = ((it.get("fields") or {}).get("comment") or {}).get("comments") if isinstance(it, dict) else None
1003
+ days = [_day(c.get("updated") or c.get("created")) for c in cm or [] if isinstance(c, dict)]
1004
+ days = [d for d in days if d]
1005
+ if k and days:
1006
+ out[k] = max(days)
1007
+ return out
1008
+
1009
+
1010
+ def prev_status(path: str) -> dict:
1011
+ try:
1012
+ with open(path, encoding="utf-8") as f:
1013
+ o = json.load(f)
1014
+ return o if isinstance(o, dict) else {}
1015
+ except (OSError, ValueError):
1016
+ return {}
1017
+
1018
+
1019
+ ABANDON_RES = {"won't do", "wont do", "duplicate", "cannot reproduce", "cancelled", "canceled", "archived", "won't fix", "declined", "rejected", "obsolete"}
1020
+ ABANDON_ST = {"abandoned", "cancelled", "canceled", "rejected", "closed-incomplete", "closed incomplete", "expired", "not applicable", "de-commissioned", "decommissioned", "withdrawn", "won't do"}
1021
+
1022
+
1023
+ def issue_state(j: dict, smap: dict, rmap: dict, done_cols: set, columns: list | None = None) -> str:
1024
+ """The same reading of a Jira status the page uses: the team's status map, abandoned resolutions and statuses,
1025
+ the board's columns, then Jira's category. Returns done, abandoned, inprogress or notstarted.
1026
+ done_cols remains the fallback for older callers that do not supply all the columns."""
1027
+ st, res, cat = str(j.get("status") or ""), str(j.get("resolution") or ""), str(j.get("category") or "")
1028
+ status_key = st.strip().lower()
1029
+ if smap.get(st) in ("done", "abandoned", "inprogress", "notstarted"):
1030
+ return smap[st]
1031
+ if res and (rmap.get(res) == "abandoned" or (not rmap.get(res) and res.strip().lower() in ABANDON_RES)):
1032
+ return "abandoned"
1033
+ if status_key in ABANDON_ST:
1034
+ return "abandoned"
1035
+ if columns is not None:
1036
+ # Match the first column containing the status, ignoring case and surrounding spaces, like boardColumnOf.
1037
+ for i, col in enumerate(columns):
1038
+ if isinstance(col, dict) and any(str(value).strip().lower() == status_key for value in col.get("statuses") or []):
1039
+ return "done" if i == len(columns) - 1 else "notstarted" if cat == "todo" else "inprogress"
1040
+ elif any(str(value).strip().lower() == status_key for value in done_cols):
1041
+ return "done"
1042
+ return {"todo": "notstarted", "inprogress": "inprogress", "done": "done"}.get(cat, "notstarted")
1043
+
1044
+
1045
+ def burndown_snapshot(out: dict, prev: dict, more: dict, plan: dict | None = None) -> None:
1046
+ """Each run notes, for the active sprint, the points in it (scope) and the points not done yet, by day, carrying
1047
+ earlier days forward from the last file. So the burndown shows scope added mid-sprint, not just what closed."""
1048
+ hist = prev.get("burndown") if isinstance(prev.get("burndown"), dict) else {}
1049
+ hist = {k: v for k, v in hist.items() if re.match(r"^[0-9]{1,9}$", str(k)) and isinstance(v, dict)}
1050
+ active = [x for x in out.get("sprints") or [] if x.get("state") == "active"]
1051
+ today = _dt.date.today().isoformat()
1052
+ every = {**(more or {}), **(out.get("issues") or {})}
1053
+ meta = (plan or {}).get("meta") if isinstance((plan or {}).get("meta"), dict) else {}
1054
+ smap = meta.get("jiraStatusMap") if isinstance(meta.get("jiraStatusMap"), dict) else {}
1055
+ rmap = meta.get("jiraResMap") if isinstance(meta.get("jiraResMap"), dict) else {}
1056
+ cols = ((out.get("board") or {}).get("columns") or [])
1057
+ done_cols = set(cols[-1].get("statuses") or []) if cols and isinstance(cols[-1], dict) else set()
1058
+ for sp in active:
1059
+ sid = str(sp["id"])
1060
+ mine = [j for j in every.values() if isinstance(j, dict) and j.get("sprintId") == sp["id"] and not j.get("isEpic")]
1061
+ state = {id(j): issue_state(j, smap, rmap, done_cols, cols) for j in mine}
1062
+ live = [j for j in mine if state[id(j)] != "abandoned"] # abandoned work isn't scope, and isn't "done" either
1063
+ num = lambda j: j["points"] if isinstance(j.get("points"), (int, float)) else 0
1064
+ days = dict(hist.get(sid) or {})
1065
+ days[today] = {"scope": round(sum(num(j) for j in live), 2), "left": round(sum(num(j) for j in live if state[id(j)] != "done"), 2), "stories": len(live),
1066
+ "unestimated": sum(1 for j in live if not isinstance(j.get("points"), (int, float))), "abandoned": len(mine) - len(live)}
1067
+ hist[sid] = dict(sorted(days.items())[-MAX_BURN_DAYS:])
1068
+ if hist:
1069
+ out["burndown"] = dict(sorted(hist.items(), key=lambda kv: max(kv[1]) if kv[1] else "")[-MAX_BURN_SPRINTS:])
1070
+
1071
+
1072
+ def board_sprints(base: str, token: str, board: str, opener=None) -> tuple[dict, list]:
1073
+ """A Scrum board's name and its sprints (closed, active and future), from Jira's Agile API. Read-only GETs."""
1074
+ if not BOARD_RE.match(str(board)):
1075
+ raise SystemExit("--board takes the board's number, the rapidView=123 in the board's address.")
1076
+ opener = opener or jira_opener()
1077
+
1078
+ def get(path: str) -> dict:
1079
+ try:
1080
+ with opener.open(auth_request(f"{base}{path}", token), timeout=TIMEOUT) as r:
1081
+ body = json.loads(r.read(5 * 1024 * 1024).decode("utf-8"))
1082
+ return body if isinstance(body, dict) else {}
1083
+ except urllib.error.HTTPError as e:
1084
+ if 300 <= e.code < 400:
1085
+ raise redirect_exit(e)
1086
+ if e.code == 404:
1087
+ raise SystemExit(f"Jira has no board {board}, or you can't see it.")
1088
+ if e.code == 400:
1089
+ why = jira_error_text(e)
1090
+ raise SystemExit(f"Jira couldn't list that board's sprints" + (f": {why}" if why else ".") + " Is it a Scrum board?")
1091
+ raise SystemExit("Jira refused the token (401/403)." if e.code in (401, 403) else f"Jira answered with an error ({e.code}).")
1092
+ except urllib.error.URLError as e:
1093
+ raise SystemExit(f"Couldn't reach Jira: {e.reason}")
1094
+
1095
+ info = get(f"/rest/agile/1.0/board/{board}")
1096
+ if clean(info.get("type"), 20).lower() not in ("scrum", ""):
1097
+ raise SystemExit(f"Board {board} is a {clean(info.get('type'), 20)} board, which has no sprints.")
1098
+ sprints, start = [], 0
1099
+ while len(sprints) < MAX_SPRINTS:
1100
+ page = get(f"/rest/agile/1.0/board/{board}/sprint?" + urllib.parse.urlencode({"startAt": str(start), "maxResults": "50"}))
1101
+ vals = page.get("values") if isinstance(page.get("values"), list) else []
1102
+ for v in vals:
1103
+ if not isinstance(v, dict) or not isinstance(v.get("id"), int) or not 0 < v["id"] < 10**9:
1104
+ continue
1105
+ sprints.append({"id": v["id"], "name": clean(v.get("name"), 80), "state": clean(v.get("state"), 20).lower(),
1106
+ "start": _day(v.get("startDate")), "end": _day(v.get("endDate")), "goal": clean(v.get("goal"), 300)})
1107
+ start += len(vals)
1108
+ if not vals or page.get("isLast") is True:
1109
+ break
1110
+ columns = []
1111
+ try:
1112
+ conf = get(f"/rest/agile/1.0/board/{board}/configuration")
1113
+ cols = (conf.get("columnConfig") or {}).get("columns") if isinstance(conf.get("columnConfig"), dict) else None
1114
+ if isinstance(cols, list):
1115
+ ids = {str(st.get("id")) for c in cols if isinstance(c, dict) for st in (c.get("statuses") or []) if isinstance(st, dict)}
1116
+ names = {}
1117
+ if ids:
1118
+ all_st = get_json(base, token, "/rest/api/2/status", opener)
1119
+ names = {str(x.get("id")): clean(x.get("name"), 60) for x in all_st if isinstance(x, dict)} if isinstance(all_st, list) else {}
1120
+ for c in cols[:30]:
1121
+ if isinstance(c, dict):
1122
+ columns.append({"name": clean(c.get("name"), 60), "statuses": [names[str(st.get("id"))] for st in (c.get("statuses") or [])
1123
+ if isinstance(st, dict) and str(st.get("id")) in names][:60]})
1124
+ except SystemExit as e: # the board's columns are a nicety: without them, Jira's categories are used
1125
+ columns = []
1126
+ print(f" couldn't read board {board}'s columns ({e}); statuses will use Jira's own categories", file=sys.stderr)
1127
+ return {"id": int(board), "name": clean(info.get("name"), 120), "columns": columns}, sprints[:MAX_SPRINTS]
1128
+
1129
+
1130
+ # ---------------------------------------------------------------- commands
1131
+ def cmd_setup(args) -> None:
1132
+ cfg = load_config()
1133
+ sv = servers(cfg)
1134
+ dflt = sv.get(cfg.get("default") or "", {}).get("base", "https://jira.yourcompany.com")
1135
+ base = normalize_base(getattr(args, "jira", None) or input(f"Jira address [{dflt}]: ") or dflt)
1136
+ print("Create a personal access token in Jira: your profile picture → Profile → Personal Access Tokens.")
1137
+ print("Read access is enough. The token is stored in your system's credential store, not in a file.")
1138
+ token = getpass.getpass("Personal access token (hidden): ").strip()
1139
+ if not token:
1140
+ raise SystemExit("No token entered.")
1141
+ host = urllib.parse.urlparse(base).hostname
1142
+ try: # check the token works before saving it
1143
+ opener = jira_opener()
1144
+ req = auth_request(f"{base}/rest/api/2/myself", token)
1145
+ with opener.open(req, timeout=TIMEOUT) as r:
1146
+ me = json.loads(r.read(1024 * 1024).decode("utf-8"))
1147
+ except urllib.error.HTTPError as e:
1148
+ if 300 <= e.code < 400:
1149
+ raise redirect_exit(e)
1150
+ raise SystemExit("Jira refused that token." if e.code in (401, 403) else f"Jira answered with an error ({e.code}).")
1151
+ except urllib.error.URLError as e:
1152
+ raise SystemExit(f"Couldn't reach Jira: {e.reason}")
1153
+ CredentialStore(host).save(token)
1154
+ fields = {}
1155
+ try:
1156
+ fields = find_fields(base, token, opener)
1157
+ except SystemExit as e:
1158
+ print(f"Couldn't look up your Jira's fields ({e}). Run `fields` later.")
1159
+ sv[host] = {"base": base, "fields": fields}
1160
+ cfg.setdefault("default", host)
1161
+ save_config(cfg)
1162
+ print(f"Saved {host}. Signed in to Jira as {clean(me.get('displayName'), 80) or 'you'}." + (f" You now have {len(sv)} Jira servers set up." if len(sv) > 1 else ""))
1163
+ if fields:
1164
+ print("Found these fields in your Jira:")
1165
+ report_fields(fields)
1166
+
1167
+
1168
+ def find_fields(base: str, token: str, opener=None) -> dict:
1169
+ opener = opener or jira_opener()
1170
+ try:
1171
+ with opener.open(auth_request(f"{base}/rest/api/2/field", token), timeout=TIMEOUT) as r:
1172
+ return discover_fields(json.loads(r.read(5 * 1024 * 1024).decode("utf-8")))
1173
+ except urllib.error.HTTPError as e:
1174
+ if 300 <= e.code < 400:
1175
+ raise redirect_exit(e)
1176
+ raise SystemExit("Jira refused the token (401/403)." if e.code in (401, 403) else f"Jira answered with an error ({e.code}).")
1177
+ except urllib.error.URLError as e:
1178
+ raise SystemExit(f"Couldn't reach Jira: {e.reason}")
1179
+
1180
+
1181
+ FIELD_LABELS = {"epicLink": "Epic Link", "epicName": "Epic Name", "sprint": "Sprint", "storyPoints": "Story Points", "parentLink": "Parent Link (Advanced Roadmaps)"}
1182
+
1183
+
1184
+ def report_fields(found: dict) -> None:
1185
+ for role, label in FIELD_LABELS.items():
1186
+ print(f" {label:32} {found.get(role) or 'not found'}")
1187
+ if "epicLink" not in found and "parentLink" not in found:
1188
+ print(" No Epic Link or Parent Link field: epics will be read from each issue's parent, if it has one.")
1189
+
1190
+
1191
+ def cmd_fields(args) -> None:
1192
+ cfg = load_config()
1193
+ entry = pick_server(cfg, getattr(args, "jira", None))
1194
+ fields = dict(entry.get("fields") or {})
1195
+ if args.set:
1196
+ for pair in args.set:
1197
+ role, _, fid = pair.partition("=")
1198
+ if role not in FIELD_LABELS or not FIELD_ID_RE.match(fid):
1199
+ raise SystemExit(f"--set takes ROLE=customfield_NNN, with ROLE one of {', '.join(FIELD_LABELS)}.")
1200
+ fields[role] = fid
1201
+ print("Saved your field choices:")
1202
+ else:
1203
+ base, token = use_server(entry)
1204
+ fields = find_fields(base, token)
1205
+ print(f"Found these fields in {urllib.parse.urlparse(base).hostname}:")
1206
+ entry["fields"] = fields
1207
+ save_config(cfg)
1208
+ report_fields(fields)
1209
+ print("To change one yourself: fields --set ROLE=customfield_NNNNN (for example storyPoints=customfield_9900001)")
1210
+
1211
+
1212
+ def get_json(base: str, token: str, path: str, opener=None, cap: int = 5 * 1024 * 1024):
1213
+ """One read-only GET. Same redirect and error handling as the rest of the script."""
1214
+ opener = opener or jira_opener()
1215
+ try:
1216
+ with opener.open(auth_request(f"{base}{path}", token), timeout=TIMEOUT) as r:
1217
+ return json.loads(r.read(cap).decode("utf-8"))
1218
+ except urllib.error.HTTPError as e:
1219
+ if 300 <= e.code < 400:
1220
+ raise redirect_exit(e)
1221
+ if e.code == 400:
1222
+ why = jira_error_text(e)
1223
+ raise SystemExit("Jira couldn't do that" + (f": {why}" if why else "."))
1224
+ raise SystemExit("Jira refused the token (401/403)." if e.code in (401, 403) else f"Jira answered with an error ({e.code}) for {path.split('?')[0]}.")
1225
+ except urllib.error.URLError as e:
1226
+ raise SystemExit(f"Couldn't reach Jira: {e.reason}")
1227
+
1228
+
1229
+ def _short(v, n=160) -> str:
1230
+ """A raw field value on one line, capped, for looking at its format."""
1231
+ t = json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else v
1232
+ t = " ".join(t.split())
1233
+ return t if len(t) <= n else t[:n] + "…"
1234
+
1235
+
1236
+ def inspect_report(base: str, token: str, board: str | None, jql: str | None, opener=None, sample: int = 3) -> list[str]:
1237
+ """What this Jira returns, so its format can be checked: statuses and their categories, resolutions, a board's
1238
+ sprints, and a few sample issues' raw sprint / resolution / epic values. No people: no assignees, no emails."""
1239
+ out = []
1240
+ sts = get_json(base, token, "/rest/api/2/status", opener)
1241
+ out.append(f"Statuses ({len(sts) if isinstance(sts, list) else 0}), and the category Sightline maps each to:")
1242
+ cats = {"new": "to do", "indeterminate": "in progress", "done": "done"}
1243
+ for st in sorted([x for x in sts if isinstance(x, dict)] if isinstance(sts, list) else [], key=lambda x: clean(x.get("name"), 60).lower()):
1244
+ cat = st.get("statusCategory") if isinstance(st.get("statusCategory"), dict) else {}
1245
+ out.append(f" {clean(st.get('name'), 60):32} {cats.get(clean(cat.get('key'), 20), 'unknown (' + clean(cat.get('key'), 20) + ')')}")
1246
+ res = get_json(base, token, "/rest/api/2/resolution", opener)
1247
+ out.append("")
1248
+ out.append(f"Resolutions ({len(res) if isinstance(res, list) else 0}):")
1249
+ for r in [x for x in res if isinstance(x, dict)] if isinstance(res, list) else []:
1250
+ out.append(f" {clean(r.get('name'), 60):32} {clean(r.get('description'), 90)}")
1251
+ if board:
1252
+ b, sprints = board_sprints(base, token, board, opener)
1253
+ out.append("")
1254
+ out.append(f"Board {b['id']} ({b['name'] or 'no name'}): {len(sprints)} sprint(s), newest last")
1255
+ for x in sprints[-15:]:
1256
+ out.append(f" {x['id']:>7} {x['name'][:40]:40} {x['state']:8} {x['start'] or '-':10} to {x['end'] or '-'}")
1257
+ if jql:
1258
+ q = jql_for(jql, None)
1259
+ body = get_json(base, token, "/rest/api/2/search?" + urllib.parse.urlencode({"jql": q, "fields": fields_param(), "maxResults": str(sample)}), opener)
1260
+ issues = body.get("issues") if isinstance(body, dict) and isinstance(body.get("issues"), list) else []
1261
+ out.append("")
1262
+ out.append(f"Sample issues from: {q}")
1263
+ for raw in [x for x in issues if isinstance(x, dict)][:sample]:
1264
+ f = raw.get("fields") if isinstance(raw.get("fields"), dict) else {}
1265
+ st = f.get("status") if isinstance(f.get("status"), dict) else {}
1266
+ rs = f.get("resolution") if isinstance(f.get("resolution"), dict) else {}
1267
+ typ = f.get("issuetype") if isinstance(f.get("issuetype"), dict) else {}
1268
+ out.append(f" {clean(raw.get('key'), 40)} type {clean(typ.get('name'), 40)!r} · status {clean(st.get('name'), 60)!r} · resolution {clean(rs.get('name'), 60) or 'none'!r}")
1269
+ for role in ("sprint", "epicLink", "epicName", "storyPoints", "parentLink"):
1270
+ fid = CUSTOM.get(role, "")
1271
+ if FIELD_ID_RE.match(fid):
1272
+ out.append(f" {role:12} {fid:18} raw: {_short(f.get(fid))}")
1273
+ shaped = shape_issue(raw, base)
1274
+ if shaped:
1275
+ v = shaped[1]
1276
+ out.append(f" Sightline reads: sprint {v['sprint']!r} ({v['sprintStart'] or '-'} to {v['sprintEnd'] or '-'}), epic {v['epic'] or '-'}, "
1277
+ f"points {v['points'] if v['points'] is not None else '-'}, isEpic {v['isEpic']}")
1278
+ return out
1279
+
1280
+
1281
+ def cmd_inspect(args) -> None:
1282
+ base, token = use_server(pick_server(load_config(), getattr(args, "jira", None)))
1283
+ print("\n".join(inspect_report(base, token, args.board, args.jql, sample=max(1, min(10, args.sample)))))
1284
+ print("\nNo assignees or emails are shown. Nothing was changed in Jira.")
1285
+
1286
+
1287
+ def cmd_forget(args) -> None:
1288
+ cfg = load_config()
1289
+ if not servers(cfg):
1290
+ print("Nothing to forget.")
1291
+ return
1292
+ entry = pick_server(cfg, getattr(args, "jira", None))
1293
+ host = urllib.parse.urlparse(entry["base"]).hostname
1294
+ ok = CredentialStore(host).delete()
1295
+ servers(cfg).pop(host, None)
1296
+ if cfg.get("default") == host:
1297
+ cfg["default"] = next(iter(servers(cfg)), None)
1298
+ save_config(cfg)
1299
+ print(f"Removed the stored token for {host}." if ok else f"No stored token found for {host}; removed its settings.")
1300
+
1301
+
1302
+ PAGE_META = re.compile(rb'<meta name="sightline-version" content="([0-9]{1,4}\.[0-9]{1,5}\.[0-9]{1,6})">')
1303
+
1304
+
1305
+ def newest_page(folder: str) -> dict | None:
1306
+ """The newest Sightline page among the .html files in the plan's folder: {version, file}. Only the version stamp in
1307
+ each file's first few KB is read, and only the file's name (no path) is written."""
1308
+ best = None
1309
+ try:
1310
+ names = sorted(n for n in os.listdir(folder) if n.lower().endswith((".html", ".htm")))[:2000] # every page, within reason
1311
+ except OSError:
1312
+ return None
1313
+ for name in names:
1314
+ if not name.lower().endswith((".html", ".htm")):
1315
+ continue
1316
+ try:
1317
+ with open(os.path.join(folder, name), "rb") as f:
1318
+ m = PAGE_META.search(f.read(8192))
1319
+ except OSError:
1320
+ continue
1321
+ if m:
1322
+ v = m.group(1).decode()
1323
+ if best is None or tuple(map(int, v.split("."))) > tuple(map(int, best["version"].split("."))):
1324
+ best = {"version": v, "file": clean(name, 120)}
1325
+ return best
1326
+
1327
+
1328
+ def sync_once(plan_path: str, out_path: str, base: str, token: str, jql: str | None = None, limit: int = 500,
1329
+ issues_path: str | None = None, board: str | None = None, how: dict | None = None, accept_drop: bool = False) -> dict:
1330
+ if os.path.getsize(plan_path) > MAX_PLAN_BYTES:
1331
+ raise SystemExit("The plan file is larger than 5 MB.")
1332
+ with open(plan_path, encoding="utf-8") as f:
1333
+ plan = json.load(f)
1334
+ check_plan_format(plan)
1335
+ linked: list = []
1336
+ keys = plan_keys(plan, linked)
1337
+ issues = jira_search(base, token, keys) if keys else {}
1338
+ searches = jql if isinstance(jql, list) else ([{"name": "Search", "jql": jql}] if jql else [])
1339
+ issues_all, lists = {}, []
1340
+ for srch in searches:
1341
+ # Several named searches ("Epics", "This sprint", "No epic"): each keeps its own list of keys, in Jira's order,
1342
+ # and the issues are stored once. --limit applies to each search; MAX_ISSUES caps the whole file.
1343
+ room = min(limit, MAX_ISSUES - len(issues_all))
1344
+ if room <= 0: # the file is full: say so rather than fetch past the cap
1345
+ lists.append({"name": srch["name"], "jql": srch["jql"], "total": 0, "truncated": True, "keys": [], "full": True})
1346
+ continue
1347
+ found, order, total = jira_filter(base, token, srch["jql"], room)
1348
+ for k in order:
1349
+ issues_all.setdefault(k, found[k])
1350
+ lists.append({"name": srch["name"], "jql": srch["jql"], "total": total, "truncated": total > len(order), "keys": order, **({"full": True} if room < limit and total > len(order) else {})})
1351
+ # epics linked by an initiative's list, or by its own key when Jira says that key is an epic
1352
+ epics = sorted({k for k in plan_epics(plan) if k in issues} | {k for k in keys if (issues.get(k) or {}).get("isEpic")})
1353
+ # epics the searches found that aren't on the board: counted too (not listed), so the page can tell a finished
1354
+ # epic from a live one while you sort. The board's epics come first for the count limit.
1355
+ found_epics = sorted(k for k, v in issues_all.items() if v.get("isEpic") and k not in epics)
1356
+ stories: dict = {}
1357
+ parked = parked_epics(plan)
1358
+ counted = epics + found_epics
1359
+ rollups, cut = jira_rollups(base, token, counted, stories=stories, listed={k for k in epics if k not in parked}) if counted else ({}, False)
1360
+ fold_nested(rollups, {k: issues.get(k) or issues_all.get(k) or {} for k in rollups})
1361
+ for k, v in stories.items(): # an epic's stories, for the page; issues the plan links keep their own entry
1362
+ issues.setdefault(k, v)
1363
+ keep_descriptions(issues, own_keys(plan))
1364
+ keep_descriptions(issues_all, set()) # the search file never carries them
1365
+ ancestors = jira_ancestors(base, token, {**issues_all, **issues})
1366
+ out = {"kind": "sightline-jira-status", "version": STATUS_FORMAT, "snapshot": secrets.token_hex(8), # both files from one run share it
1367
+ "generatedAt": _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"),
1368
+ "jiraBase": base, "issues": issues, "missing": [k for k in keys if k not in issues],
1369
+ **({"keysCut": {"linked": linked[0], "fetched": len(keys)}} if linked and linked[0] > len(keys) else {}),
1370
+ **({"ancestors": ancestors} if ancestors else {}),
1371
+ # without Parent Link, a story under a feature can't be traced to its epic: the page says so where it shows
1372
+ **({} if FIELD_ID_RE.match(CUSTOM.get("parentLink", "")) else {"noParentLink": True}),
1373
+ # how this file is kept up to date, so the page can say when it's overdue (no computer or user names)
1374
+ "sync": {"by": (how or {}).get("by", "sync"), "every": int((how or {}).get("every") or 0), "plan": os.path.basename(plan_path)[:200]}}
1375
+ if board:
1376
+ out["board"], out["sprints"] = board_sprints(base, token, board)
1377
+ page = newest_page(os.path.dirname(os.path.abspath(plan_path)))
1378
+ if page:
1379
+ out["page"] = page # so an open page can say a newer copy of itself is there
1380
+ rel_keys = list(issues) + [k for k in issues_all if k not in issues]
1381
+ prev_rel = prev_status(out_path)
1382
+ fresh = prev_rel.get("releasesAt") and isinstance(prev_rel.get("releases"), list) and (_dt.datetime.now(_dt.timezone.utc) - _dt.datetime.fromisoformat(prev_rel["releasesAt"])).total_seconds() < RELEASE_TTL
1383
+ if rel_keys and fresh:
1384
+ out["releases"], out["releasesAt"] = prev_rel["releases"], prev_rel["releasesAt"] # releases change rarely: reread every few hours
1385
+ if prev_rel.get("releasesSkipped"):
1386
+ out["releasesSkipped"] = prev_rel["releasesSkipped"]
1387
+ elif rel_keys:
1388
+ out["releases"], skipped_rel = project_releases(base, token, rel_keys)
1389
+ out["releasesAt"] = out["generatedAt"]
1390
+ if skipped_rel:
1391
+ out["releasesSkipped"] = skipped_rel
1392
+ prev_st = prev_status(out_path)
1393
+ burndown_snapshot(out, prev_st, issues_all, plan)
1394
+ card_keys = [k for k, v in {**issues_all, **issues}.items() if isinstance(v, dict) and v.get("sprintState") in ("active", "future") and not v.get("isEpic")]
1395
+ every = {**issues_all, **issues}
1396
+ # the active sprint's work: open issues first (their pull requests change), then done ones (for merged pull requests)
1397
+ act = [k for k in card_keys if every[k].get("jid") and every[k].get("sprintState") == "active"]
1398
+ done_keys = {k for k in act if every[k].get("category") == "done"}
1399
+ open_i = [(k, every[k]["jid"]) for k in act if k not in done_keys]
1400
+ done_i = [(k, every[k]["jid"]) for k in act if k in done_keys]
1401
+ room = max(MAX_DEV // 4, MAX_DEV - len(open_i)) # a quarter of the room is kept for done issues' merged pull requests
1402
+ dev_items = open_i[:MAX_DEV - min(room, len(done_i))] + done_i[:room]
1403
+ if dev_items:
1404
+ dev = dev_status(base, token, dev_items, prev_st.get("dev") if isinstance(prev_st.get("dev"), dict) else {}, out["generatedAt"], done=done_keys)
1405
+ if dev:
1406
+ out["dev"] = dev
1407
+ out["devCover"] = {"read": sum(1 for k, _ in dev_items if k in (dev or {})), "of": len(dev_items)} # so the page can say when it's partial
1408
+ if card_keys:
1409
+ old = prev_st.get("issues") if isinstance(prev_st.get("issues"), dict) else {}
1410
+ cm, ask = {}, []
1411
+ for k in card_keys:
1412
+ o, n = old.get(k) or {}, {**issues_all, **issues}.get(k) or {}
1413
+ if o.get("commentedAt") and o.get("updated") and o.get("updated") == n.get("updated"):
1414
+ cm[k] = o["commentedAt"] # nothing changed since: no need to ask Jira again
1415
+ else:
1416
+ ask.append(k)
1417
+ cm.update(last_comments(base, token, ask) if ask else {})
1418
+ for k, d in cm.items():
1419
+ for src in (issues, issues_all):
1420
+ if k in src:
1421
+ src[k]["commentedAt"] = d
1422
+ if counted:
1423
+ out["rollups"] = rollups
1424
+ out["rollupsTruncated"] = cut
1425
+ # A guard against a bad sync: if this run found far fewer of the plan's own Jira items than the last good one (a
1426
+ # permissions change, a Jira outage returning nothing, a search that broke), keep the previous files and say so,
1427
+ # rather than making the team's work look like it vanished. --accept-drop takes the new result when the drop is real.
1428
+ prev = None
1429
+ try:
1430
+ with open(out_path, encoding="utf-8") as f:
1431
+ prev = json.load(f)
1432
+ except (OSError, ValueError):
1433
+ prev = None
1434
+ if isinstance(prev, dict) and isinstance(prev.get("issues"), dict) and not accept_drop:
1435
+ before = sum(1 for k in keys if k in prev["issues"])
1436
+ now = sum(1 for k in keys if k in issues)
1437
+ if (before and now == 0) or (before >= DROP_MIN and now < before * DROP_KEEP): # losing all of them is guarded at any size
1438
+ prev["guard"] = {"at": out["generatedAt"], "found": now, "previous": before, "plan": os.path.basename(plan_path)[:200]}
1439
+ write_atomic(out_path, prev)
1440
+ return {**out, "guarded": prev["guard"]}
1441
+ if searches:
1442
+ listing = {"kind": "sightline-jira-issues", "version": ISSUES_FORMAT, "snapshot": out["snapshot"], "generatedAt": out["generatedAt"], "jiraBase": base,
1443
+ "searches": lists, "issues": [{"key": k, **v} for k, v in issues_all.items()],
1444
+ # version-1 fields, so an older page still shows the first search
1445
+ "jql": lists[0]["jql"], "total": lists[0]["total"], "truncated": lists[0]["truncated"]}
1446
+ write_atomic(issues_path or os.path.join(os.path.dirname(os.path.abspath(out_path)), "jira-issues.json"), listing)
1447
+ out["search"] = [{"name": x["name"], "found": x["total"], "kept": len(x["keys"]), "truncated": x["truncated"], "full": x.get("full", False)} for x in lists] # for the console only
1448
+ write_atomic(out_path, {k: v for k, v in out.items() if k != "search"})
1449
+ return out
1450
+
1451
+
1452
+ DROP_MIN = 10 # a partial drop is guarded only for plans linking at least this many Jira items
1453
+ DROP_KEEP = 2 / 3 # and keeps the earlier sync when fewer than this share of them came back
1454
+
1455
+
1456
+ SLUG_RE = re.compile(r"[^A-Za-z0-9._-]+")
1457
+
1458
+
1459
+ def folder_slug(name: str) -> str:
1460
+ """A board's name as a safe folder name: letters, digits, . _ - only (no path separators, no leading dots)."""
1461
+ return SLUG_RE.sub("-", clean(name, 60)).strip("-.")[:40]
1462
+
1463
+
1464
+ def jira_folder(plan: str, folder: str | None) -> str:
1465
+ """Where the Jira files live: a jira/ folder beside the plan, so the plan and its Jira data move together when
1466
+ someone copies the folder for their own project. --folder (a subfolder name, or a path) overrides it."""
1467
+ here = os.path.dirname(os.path.abspath(plan))
1468
+ if not folder:
1469
+ return os.path.join(here, "jira")
1470
+ if os.path.isabs(folder) or os.sep in folder or (os.altsep and os.altsep in folder):
1471
+ return os.path.abspath(folder)
1472
+ return os.path.join(here, folder_slug(folder) or "jira")
1473
+
1474
+
1475
+ def plan_job(cfg: dict, plan: str, args) -> dict:
1476
+ """What to sync for a plan: what was saved for it with --save, with each option on the command line replacing
1477
+ just that setting (--board 8 changes the board and keeps the saved searches)."""
1478
+ sp = saved_plan(cfg, plan)
1479
+ a = lambda k: getattr(args, k, None)
1480
+ searches = parse_searches(a("search"))
1481
+ if a("jql") is not None or a("filter") is not None:
1482
+ searches.insert(0, {"name": "Filter " + str(a("filter")) if a("filter") is not None else "Search", "jql": jql_for(a("jql"), a("filter"))})
1483
+ saved_s = sp.get("searches") if isinstance(sp.get("searches"), list) else ([{"name": "Search", "jql": sp["jql"]}] if sp.get("jql") else [])
1484
+ saved_s = [{"name": x["name"], "jql": jql_for(x["jql"], None)} for x in saved_s if isinstance(x, dict) and SEARCH_NAME_RE.match(str(x.get("name", ""))) and x.get("jql")]
1485
+ pick = lambda k: a(k) if a(k) is not None else sp.get(k)
1486
+ job = {"jira": a("jira"), "board": pick("board"), "folder": pick("folder"), "limit": a("limit") or sp.get("limit") or 500,
1487
+ "every": a("every") or sp.get("every") or 15, "jql": searches or saved_s or None, "accept_drop": bool(a("accept_drop"))}
1488
+ entry = pick_server(cfg, job["jira"], sp)
1489
+ job["server"] = urllib.parse.urlparse(entry["base"]).hostname
1490
+ job["entry"] = entry
1491
+ return job
1492
+
1493
+
1494
+ def check_plan(plan: str, shown: str) -> None:
1495
+ if not os.path.isfile(plan):
1496
+ here = os.path.dirname(plan)
1497
+ near = sorted(f for f in os.listdir(here) if f.lower().endswith(".json") and not f.startswith(("jira-status", "jira-issues", "."))) if os.path.isdir(here) else []
1498
+ raise SystemExit(f"Can't find the plan file {shown}." + (f" Plan files in that folder: {', '.join(near[:10])}" if near else " Check the name and folder."))
1499
+
1500
+
1501
+ def run_job(plan: str, job: dict, out: str | None = None, issues_out: str | None = None, how: dict | None = None) -> tuple[dict, str, str]:
1502
+ base, token = use_server(job["entry"])
1503
+ folder = jira_folder(plan, job.get("folder"))
1504
+ out = out or os.path.join(folder, "status.json")
1505
+ issues_out = issues_out or os.path.join(folder, "issues.json")
1506
+ if issues_out == out:
1507
+ raise SystemExit("--issues-out must be a different file from the status file.")
1508
+ os.makedirs(os.path.dirname(out), exist_ok=True)
1509
+ os.makedirs(os.path.dirname(issues_out), exist_ok=True)
1510
+ res = sync_once(plan, out, base, token, job.get("jql"), max(1, min(2000, int(job.get("limit") or 500))), issues_out, job.get("board"), how, bool(job.get("accept_drop")))
1511
+ return res, out, issues_out
1512
+
1513
+
1514
+ def report_run(res: dict, out: str, issues_out: str, label: str = "") -> None:
1515
+ stamp = _dt.datetime.now().strftime("%H:%M")
1516
+ if res.get("guarded"):
1517
+ g = res["guarded"]
1518
+ print(f"[{stamp}] {label}Kept the earlier sync: Jira returned only {g['found']} of this plan's {g['previous']} Jira items this time "
1519
+ "(a permissions change, a Jira problem, or a changed search?). Nothing was replaced. If the drop is real, run sync with --accept-drop.")
1520
+ return
1521
+ kids = sum(len(r.get("children") or []) for r in (res.get("rollups") or {}).values())
1522
+ print(f"[{stamp}] {label}{len(res['issues'])} issue(s) written to {out}" + (f" ({kids} of them stories under your epics)" if kids else "") + (f"; not found in Jira: {', '.join(res['missing'][:10])}" if res["missing"] else ""))
1523
+ if res.get("noParentLink"):
1524
+ print(" no Parent Link field found: a story that sits under a feature will show with no epic. Run fields to check.")
1525
+ if res.get("keysCut"):
1526
+ print(f" this plan links {res['keysCut']['linked']} Jira items; only the first {res['keysCut']['fetched']} were fetched. Remove links the plan no longer needs.")
1527
+ if "sprints" in res:
1528
+ print(f" board {res['board']['name'] or res['board']['id']}: {len(res['sprints'])} sprint(s), {len(res['board'].get('columns') or [])} column(s)")
1529
+ if res.get("dev"):
1530
+ opn = [p for d in res["dev"].values() for p in d.get("prs", []) if p.get("status") == "OPEN"]
1531
+ print(f" pull requests: {len(opn)} open, across {sum(1 for d in res['dev'].values() if any(p.get('status') == 'OPEN' for p in d.get('prs', [])))} of the sprint's issues")
1532
+ if res.get("releases") is not None or res.get("releasesSkipped"):
1533
+ rel = res.get("releases") or []
1534
+ print(f" releases: {len(rel)} from {', '.join(sorted({r['project'] for r in rel})) or 'no project'}"
1535
+ + (f"; couldn't read {', '.join(res['releasesSkipped'])}" if res.get("releasesSkipped") else ""))
1536
+ for fl in res.get("search") or []:
1537
+ if fl["full"] and not fl["found"]:
1538
+ print(f" {fl['name']}: skipped; the file already holds {MAX_ISSUES} issues. Narrow the searches above it.")
1539
+ else:
1540
+ print(f" {fl['name']}: found {fl['found']} issue(s)" + (f"; only the first {fl['kept']} were kept (" + (f"the file holds {MAX_ISSUES} issues across all searches" if fl["full"] else "--limit") + ")" if fl["truncated"] else ""))
1541
+ if res.get("search"):
1542
+ print(f" written to {issues_out}")
1543
+
1544
+
1545
+ def cmd_sync(args) -> None:
1546
+ cfg = load_config()
1547
+ plan = os.path.abspath(args.plan)
1548
+ check_plan(plan, args.plan)
1549
+ job = plan_job(cfg, plan, args)
1550
+ save = getattr(args, "save", False)
1551
+ out = os.path.abspath(args.out) if getattr(args, "out", None) else None
1552
+ issues_out = os.path.abspath(args.issues_out) if getattr(args, "issues_out", None) else None
1553
+ while True:
1554
+ res, o, io = run_job(plan, job, out, issues_out, how={"by": "sync", "every": getattr(args, "every", 0) or 0})
1555
+ report_run(res, o, io)
1556
+ if save: # only settings that just worked are kept
1557
+ cfg = load_config()
1558
+ cfg.setdefault("plans", {})[plan_key(plan)] = {"jira": job["server"], "board": job.get("board"), "searches": job.get("jql") or [],
1559
+ "folder": job.get("folder"), "limit": job.get("limit") or 500, "every": job.get("every") or 15}
1560
+ save_config(cfg)
1561
+ print(f"Saved this plan's sync settings on this computer. From now on: sync \"{args.plan}\", or watch.")
1562
+ save = False
1563
+ if not getattr(args, "every", 0):
1564
+ break
1565
+ time.sleep(max(5, args.every) * 60)
1566
+
1567
+
1568
+ # ---------------------------------------------------------------- watch and install
1569
+ WATCH_TICK = 20 # seconds between checks
1570
+ SETTLE = 10 # a saved plan must be still this long before it's synced
1571
+ LOCK_FRESH = 120 # a watch that hasn't checked in for this long is gone
1572
+
1573
+
1574
+ def lock_path() -> str:
1575
+ return os.path.join(CONFIG_DIR, "watch.lock")
1576
+
1577
+
1578
+ WATCH_ID = secrets.token_hex(8) # this process's claim on the lock
1579
+
1580
+
1581
+ def read_lock() -> dict | None:
1582
+ try:
1583
+ with open(lock_path(), encoding="utf-8") as f:
1584
+ held = json.load(f)
1585
+ return held if isinstance(held, dict) else {}
1586
+ except FileNotFoundError:
1587
+ return None
1588
+ except (OSError, ValueError):
1589
+ return {}
1590
+
1591
+
1592
+ def take_watch_lock(now: float, settle: float = 1.0) -> bool:
1593
+ """One watch at a time. A heartbeat file with this process's id, instead of a pid check, which isn't safe on Windows
1594
+ (os.kill ends processes). A new lock is created exclusively; a stale one is replaced, then re-read after a moment so
1595
+ two watches starting together can't both win."""
1596
+ os.makedirs(CONFIG_DIR, exist_ok=True)
1597
+ mine = {"id": WATCH_ID, "pid": os.getpid(), "at": now}
1598
+ held = read_lock()
1599
+ if held is None:
1600
+ try:
1601
+ fd = os.open(lock_path(), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
1602
+ except FileExistsError:
1603
+ return False
1604
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
1605
+ json.dump(mine, f)
1606
+ return True
1607
+ if held.get("id") != WATCH_ID:
1608
+ try:
1609
+ fresh = now - float(held.get("at", 0)) < LOCK_FRESH
1610
+ except (TypeError, ValueError):
1611
+ fresh = False
1612
+ if fresh:
1613
+ return False
1614
+ write_atomic(lock_path(), mine)
1615
+ if settle:
1616
+ time.sleep(settle)
1617
+ return (read_lock() or {}).get("id") == WATCH_ID
1618
+ write_atomic(lock_path(), mine)
1619
+ return True
1620
+
1621
+
1622
+ def release_watch_lock() -> None:
1623
+ """On the way out (Ctrl+C included), give the lock back if it's still ours, so the next watch can start at once."""
1624
+ held = read_lock()
1625
+ if isinstance(held, dict) and held.get("id") == WATCH_ID:
1626
+ try:
1627
+ os.remove(lock_path())
1628
+ except OSError:
1629
+ pass
1630
+
1631
+
1632
+ def lock_held_msg(now: float) -> str:
1633
+ held = read_lock() or {}
1634
+ try:
1635
+ age = max(0, int(now - float(held.get("at", now))))
1636
+ except (TypeError, ValueError):
1637
+ age = 0
1638
+ pid = held.get("pid") if isinstance(held.get("pid"), int) else None
1639
+ wait = max(1, LOCK_FRESH - age)
1640
+ return (f"A watch is already running on this computer{f' (process {pid})' if pid else ''}, last checked in {age} s ago. "
1641
+ "It may be the one that starts when you sign in: a minimized \"Sightline Jira sync\" window. Close that window to stop it. "
1642
+ f"If you just stopped one some other way, try again in {wait} s.")
1643
+
1644
+
1645
+ def is_due(st: dict, every_min: float, mtime: float, now: float) -> bool:
1646
+ """Sync when the interval is up, or when the plan was saved since the last sync and has settled."""
1647
+ if now - st.get("last", 0) >= max(5, every_min) * 60:
1648
+ return True
1649
+ return st.get("seen") is not None and mtime != st["seen"] and now - mtime >= SETTLE
1650
+
1651
+
1652
+ RETRY_FIRST = 60 # seconds before retrying a failed sync; doubles up to the interval
1653
+
1654
+
1655
+ def watch_tick(cfg: dict, plans: list[str], state: dict, now: float, runner=None, beat=None) -> list[str]:
1656
+ """One pass over the watched plans. Returns what happened, one line per plan synced or failed. A failure in one
1657
+ plan (Jira down, a half-written plan, a full disk) is reported and retried soon; it never stops the others."""
1658
+ runner = runner or (lambda plan, job: report_run(*run_job(plan, job, how={"by": "watch", "every": job.get("every") or 15}), label=os.path.basename(plan) + ": "))
1659
+ done = []
1660
+ for plan in plans:
1661
+ st = state.setdefault(plan, {})
1662
+ try:
1663
+ mtime = os.path.getmtime(plan)
1664
+ except OSError:
1665
+ if not st.get("missing"):
1666
+ print(f"[watch] {plan} isn't there any more; skipping it until it is.")
1667
+ st["missing"] = True
1668
+ continue
1669
+ st["missing"] = False
1670
+ every = float(saved_plan(cfg, plan).get("every") or 15)
1671
+ if not is_due(st, every, mtime, now):
1672
+ continue
1673
+ try:
1674
+ runner(plan, plan_job(cfg, plan, argparse.Namespace()))
1675
+ done.append(f"synced {plan}")
1676
+ st["fails"] = 0
1677
+ st["last"] = now
1678
+ except (SystemExit, Exception) as e: # say so, and try again sooner than the full interval
1679
+ st["fails"] = st.get("fails", 0) + 1
1680
+ wait = min(max(5, every) * 60, RETRY_FIRST * 2 ** (st["fails"] - 1))
1681
+ msg = str(e) if isinstance(e, SystemExit) else f"{type(e).__name__}: {e}"
1682
+ print(f"[watch] {os.path.basename(plan)}: {clean(msg, 300).rstrip('.')}. Trying again in {int(wait // 60) or 1} min.")
1683
+ done.append(f"failed {plan}")
1684
+ st["last"] = now - max(5, every) * 60 + wait
1685
+ st["seen"] = mtime
1686
+ if beat:
1687
+ beat()
1688
+ return done
1689
+
1690
+
1691
+ def own_copy() -> str:
1692
+ return os.path.join(CONFIG_DIR, "bin", "sightline-jira-sync.py")
1693
+
1694
+
1695
+ def warn_if_shared() -> None:
1696
+ here = os.path.normcase(os.path.abspath(__file__))
1697
+ if here != os.path.normcase(own_copy()):
1698
+ print(f"Note: this script is running from {os.path.dirname(here)}. Anyone who can edit that folder could change it, "
1699
+ "and it runs with your Jira token. Run `install` to use your own copy instead.", file=sys.stderr)
1700
+
1701
+
1702
+ def cmd_watch(args) -> None:
1703
+ cfg = load_config()
1704
+ saved = cfg.get("plans") if isinstance(cfg.get("plans"), dict) else {}
1705
+ plans = [plan_key(p) for p in args.plans] if args.plans else list(saved)
1706
+ if not plans:
1707
+ raise SystemExit('Nothing to watch yet. Save a plan first: sync "path\\to\\plan.json" --board 123 --save')
1708
+ unsaved = [p for p in plans if p not in saved]
1709
+ if unsaved:
1710
+ raise SystemExit(f"Save these first with sync --save: {', '.join(unsaved)}")
1711
+ warn_if_shared()
1712
+ if not take_watch_lock(time.time()):
1713
+ raise SystemExit(lock_held_msg(time.time()))
1714
+ print(f"Watching {len(plans)} plan(s). Each syncs on its schedule, and within a minute of being saved. Ctrl+C stops.")
1715
+ state = {}
1716
+
1717
+ def beat() -> None:
1718
+ if not take_watch_lock(time.time(), settle=0):
1719
+ raise SystemExit("Another watch took over on this computer; this one is stopping.")
1720
+
1721
+ try:
1722
+ while True:
1723
+ beat()
1724
+ try:
1725
+ cfg = load_config()
1726
+ except (SystemExit, Exception) as e:
1727
+ print(f"[watch] Couldn't read the settings: {e}")
1728
+ time.sleep(WATCH_TICK)
1729
+ continue
1730
+ watch_tick(cfg, plans, state, time.time(), beat=beat)
1731
+ time.sleep(WATCH_TICK)
1732
+ except KeyboardInterrupt:
1733
+ print("Stopped watching.")
1734
+ finally:
1735
+ release_watch_lock()
1736
+
1737
+
1738
+ def startup_dir() -> str | None:
1739
+ if sys.platform != "win32" or not os.environ.get("APPDATA"):
1740
+ return None
1741
+ return os.path.join(os.environ["APPDATA"], "Microsoft", "Windows", "Start Menu", "Programs", "Startup")
1742
+
1743
+
1744
+ def launcher_text(python: str, script: str) -> str:
1745
+ q = lambda v: '"' + v.replace("%", "%%").replace('"', "") + '"' # a .cmd file expands %VAR%; paths can't hold quotes
1746
+ return f'@echo off\r\nrem Sightline Jira sync: keeps your saved plans in sync. Remove this file to stop it starting.\r\nstart "Sightline Jira sync" /min {q(python)} {q(script)} watch\r\n'
1747
+
1748
+
1749
+ def cmd_install(args) -> None:
1750
+ """Copy this script to your own folder (so a shared folder can't change what runs with your token), and start
1751
+ `watch` when you sign in to Windows."""
1752
+ dest = own_copy()
1753
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
1754
+ if os.path.normcase(os.path.abspath(__file__)) != os.path.normcase(dest):
1755
+ shutil.copyfile(os.path.abspath(__file__), dest)
1756
+ print(f"Your copy of the sync: {dest}")
1757
+ sd = startup_dir()
1758
+ if sd and not args.no_startup:
1759
+ os.makedirs(sd, exist_ok=True)
1760
+ path = os.path.join(sd, "Sightline Jira sync.cmd")
1761
+ with open(path, "w", encoding="utf-8", newline="") as f:
1762
+ f.write(launcher_text(sys.executable, dest))
1763
+ print(f"It starts when you sign in: {path}")
1764
+ print("Remove it with: uninstall")
1765
+ elif not sd:
1766
+ print(f"To keep it running, start it yourself: {sys.executable} \"{dest}\" watch")
1767
+ print("Run `sync PLAN ... --save` once per plan, and watch keeps them all in sync.")
1768
+
1769
+
1770
+ def cmd_uninstall(_args) -> None:
1771
+ sd = startup_dir()
1772
+ path = os.path.join(sd, "Sightline Jira sync.cmd") if sd else None
1773
+ if path and os.path.exists(path):
1774
+ os.remove(path)
1775
+ print(f"Removed {path}. The sync won't start when you sign in.")
1776
+ else:
1777
+ print("Nothing starts the sync when you sign in.")
1778
+
1779
+
1780
+ # ---------------- check-amendments: the page's amendment checker, offline ----------------
1781
+ # The same strict rules Sightline uses before anything is applied (version 1: deliverables and Jira proposals). The page
1782
+ # checks again when you apply, against the plan as it is then; this is for your assistant to check its own file first.
1783
+ AM_MAX_BYTES, AM_MAX_CHANGES, AM_MAX_OPS, DV_MAX_KEYS = 512 * 1024, 100, 25, 200
1784
+ AM_FIELDS = {"name": 120, "line": 300, "note": 4000, "source": 100, "kind": "kind", "stage": "stage", "target": "target", "date": "date",
1785
+ "release": "release", "lane": "lane", "owners": "people"}
1786
+ AM_ID = re.compile(r"^(?!(?:__proto__|constructor|prototype|toString|valueOf|hasOwnProperty)$)[A-Za-z0-9._-]{1,80}$")
1787
+ AM_DV_ID = re.compile(r"^dv-[A-Za-z0-9_-]{1,60}$")
1788
+ TARGET_RE = re.compile(r"^(\d{4})(?:-(Q[1-4]|H[12]))?$")
1789
+ PROPOSAL_FIELDS = ["sprint", "assignee", "points", "status", "labels+", "labels-"]
1790
+ AM_IN_FIELDS = {"label": 120, "status": "status", "owners": "people", "lane": "lane", "start": "start", "weeks": "weeks"}
1791
+ AM_TK_FIELDS = {"label": 120, "status": "status", "owners": "people", "start": "tstart", "days": "days"}
1792
+ AM_BAR_ID, AM_TASK_ID = re.compile(r"^bar-[A-Za-z0-9_-]{1,60}$"), re.compile(r"^task-[A-Za-z0-9_-]{1,60}$")
1793
+ STATUS_KEYS = ["notstarted", "inprogress", "done", "atrisk"]
1794
+
1795
+
1796
+ # Actions ({op: "action", action, args}): the page's action table, by name and argument types. harness114 checks this
1797
+ # table against the page's own. Offline, an action is checked for shape and references; its rules (loops, Jira dates,
1798
+ # blacked-out time) are checked by the page, which runs it on a copy before showing it for review.
1799
+ ACTIONS = {
1800
+ "initiative.add": ({"id": "id?", "lane": "id?", "parent": "id?", "start": "num?", "day": "iso?", "span": "num?", "label": "str?", "owners": "ids?", "tags": "ids?", "story": "str?"}, False),
1801
+ "initiative.set": ({"id": "id", "field": "str", "value": "any"}, True),
1802
+ "initiative.setStatus": ({"id": "id", "status": "str"}, True),
1803
+ "initiative.move": ({"id": "id", "start": "num?", "day": "iso?", "span": "num?"}, True),
1804
+ "initiative.setLane": ({"id": "id", "lane": "id"}, True),
1805
+ "initiative.setParent": ({"id": "id", "parent": "idOrBlank"}, True),
1806
+ "initiative.place": ({"id": "id", "target": "id", "zone": "str"}, False),
1807
+ "initiative.del": ({"id": "id"}, False),
1808
+ "initiative.setEffort": ({"id": "id", "likely": "num?", "low": "num?", "high": "num?", "at": "iso?"}, True),
1809
+ "initiative.setScope": ({"id": "id", "scope": "str"}, True),
1810
+ "initiative.delivers.add": ({"id": "id", "outcome": "id"}, False),
1811
+ "initiative.delivers.remove": ({"id": "id", "outcome": "id"}, False),
1812
+ "task.add": ({"id": "id?", "bar": "id", "start": "iso", "days": "int?", "label": "str?", "owners": "ids?", "tags": "ids?", "story": "str?"}, False),
1813
+ "task.set": ({"id": "id", "field": "str", "value": "any"}, True),
1814
+ "task.setStatus": ({"id": "id", "status": "str"}, True),
1815
+ "task.move": ({"id": "id", "start": "iso?", "days": "int?"}, True),
1816
+ "task.moveTo": ({"id": "id", "bar": "id"}, True),
1817
+ "task.link": ({"id": "id", "after": "id"}, False),
1818
+ "task.unlink": ({"id": "id", "after": "id"}, False),
1819
+ "task.del": ({"id": "id"}, False),
1820
+ "outcome.add": ({"id": "id?", "name": "str", "source": "str?", "target": "str?", "release": "id?", "date": "iso?", "jira": "keys?"}, False),
1821
+ "outcome.set": ({"id": "id", "field": "str", "value": "any"}, True),
1822
+ "outcome.setTarget": ({"id": "id", "target": "str?", "date": "iso?"}, True),
1823
+ "outcome.setRelease": ({"id": "id", "release": "idOrBlank"}, True),
1824
+ "outcome.linkJira": ({"id": "id", "keys": "keys"}, False),
1825
+ "outcome.unlinkJira": ({"id": "id", "key": "str"}, False),
1826
+ "outcome.drop": ({"id": "id", "reason": "str?"}, False),
1827
+ "outcome.del": ({"id": "id"}, True),
1828
+ "link.add": ({"id": "id", "after": "id"}, False),
1829
+ "link.remove": ({"id": "id", "after": "id"}, False),
1830
+ "initiative.waitFor": ({"id": "id", "gate": "id"}, False),
1831
+ "initiative.unwait": ({"id": "id", "gate": "id"}, False),
1832
+ "workstream.add": ({"id": "id?", "name": "str", "members": "ids?", "color": "str?"}, False),
1833
+ "workstream.rename": ({"id": "id", "name": "str"}, True),
1834
+ "workstream.addMember": ({"id": "id", "person": "id"}, False),
1835
+ "workstream.removeMember": ({"id": "id", "person": "id"}, False),
1836
+ "workstream.setReserve": ({"id": "id", "reserve": "any"}, True),
1837
+ "workstream.set": ({"id": "id", "field": "str", "value": "str"}, True),
1838
+ "workstream.move": ({"id": "id", "index": "int"}, True),
1839
+ "workstream.del": ({"id": "id"}, False),
1840
+ "guide.set": ({"section": "str", "value": "str"}, True),
1841
+ "guide.setWorkstream": ({"lane": "id", "value": "str"}, True),
1842
+ "template.set": ({"kind": "str", "value": "str"}, True),
1843
+ "skill.add": ({"id": "id", "label": "str"}, False),
1844
+ "skill.rename": ({"id": "id", "label": "str"}, True),
1845
+ "skill.remove": ({"id": "id"}, False),
1846
+ "meeting.set": ({"id": "id", "field": "str", "value": "str"}, True),
1847
+ "person.add": ({"id": "id?", "name": "str", "role": "str?", "days": "int?", "outside": "any?"}, False),
1848
+ "person.set": ({"id": "id", "field": "str", "value": "any"}, True),
1849
+ "person.setAlloc": ({"id": "id", "alloc": "any"}, True),
1850
+ "person.setSplit": ({"id": "id", "lanes": "weights"}, True),
1851
+ "person.timeOff.add": ({"id": "id", "from": "iso", "to": "iso?", "note": "str?"}, False),
1852
+ "person.timeOff.del": ({"id": "id", "from": "iso"}, False),
1853
+ "holiday.add": ({"date": "iso", "name": "str?"}, False),
1854
+ "holiday.addRange": ({"from": "iso", "to": "iso", "name": "str?"}, False),
1855
+ "holiday.del": ({"date": "iso"}, False),
1856
+ "forecast.setReserve": ({"reserve": "any"}, True),
1857
+ "milestone.add": ({"id": "id?", "what": "str", "day": "iso?", "lane": "id?", "status": "str?", "owners": "ids?", "after": "ids?"}, False),
1858
+ "milestone.move": ({"id": "id", "day": "isoOrNull", "stretch": "any?"}, True),
1859
+ "milestone.set": ({"id": "id", "field": "str", "value": "any"}, True),
1860
+ "milestone.doneWhen.add": ({"id": "id", "bar": "id"}, False),
1861
+ "milestone.doneWhen.remove": ({"id": "id", "bar": "id"}, False),
1862
+ "milestone.del": ({"id": "id"}, False),
1863
+ "gate.add": ({"id": "id?", "gate": "str", "day": "iso?", "label": "str?", "blocks": "str?", "owners": "ids?"}, False),
1864
+ "gate.move": ({"id": "id", "day": "str"}, True),
1865
+ "gate.set": ({"id": "id", "field": "str", "value": "any"}, True),
1866
+ "gate.del": ({"id": "id"}, False),
1867
+ "sprint.set": ({"id": "id", "field": "str", "value": "any"}, True),
1868
+ "sprint.add": ({"id": "id", "name": "str?", "goal": "str?"}, False),
1869
+ "sprint.addThrough": ({"date": "iso"}, False),
1870
+ "jira.toWorkstream": ({"keys": "keys", "lane": "id", "epic": "str?"}, False),
1871
+ "jira.toInitiative": ({"keys": "keys", "id": "id", "epic": "str?"}, False),
1872
+ "jira.notOurs": ({"keys": "keys"}, False),
1873
+ "jira.epicLink": ({"epic": "str", "id": "id"}, False),
1874
+ "jira.epicNew": ({"epic": "str", "lane": "id", "id": "id?"}, False),
1875
+ "risk.add": ({"id": "id?", "text": "str", "sev": "any?", "state": "any?", "owners": "any?", "item": "any?", "next": "any?", "waitingOn": "any?", "escalateBy": "any?", "aiHint": "any?"}, False),
1876
+ "risk.set": ({"id": "id", "field": "str", "value": "any"}, True),
1877
+ "risk.del": ({"id": "id"}, False),
1878
+ "decision.add": ({"id": "id?", "text": "str", "state": "any?", "owners": "any?", "item": "any?", "due": "any?", "decider": "any?", "next": "any?", "waitingOn": "any?", "escalateBy": "any?", "aiHint": "any?"}, False),
1879
+ "decision.set": ({"id": "id", "field": "str", "value": "any"}, True),
1880
+ "decision.del": ({"id": "id"}, False),
1881
+ }
1882
+ ACT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@-]{0,79}$")
1883
+ # What an id-like argument points at, by the action's first word (id) or the argument's own name.
1884
+ ACT_REF_BY_NOUN = {"initiative": "bar", "link": "bar", "task": "task", "outcome": "dv", "workstream": "lane", "person": "person", "skill": "skill", "meeting": "meeting",
1885
+ "milestone": "ms", "gate": "gate", "risk": "risk", "decision": "dec", "sprint": "sprint", "jira": "bar"}
1886
+ ACT_REF_BY_ARG = {"outcome": "dv", "after": "bar", "parent": "bar", "target": "bar", "bar": "bar", "lane": "lane", "person": "person",
1887
+ "owners": "person", "members": "person", "item": "bar", "decider": "person"}
1888
+
1889
+
1890
+ def _act_type_ok(t: str, v) -> bool:
1891
+ if t == "id":
1892
+ return isinstance(v, str) and bool(ACT_ID.match(v))
1893
+ if t == "isoOrNull":
1894
+ return v is None or _act_type_ok("iso", v)
1895
+ if t == "idOrBlank":
1896
+ return v == "" or _act_type_ok("id", v)
1897
+ if t == "ids":
1898
+ return isinstance(v, list) and len(v) <= 500 and all(_act_type_ok("id", x) for x in v)
1899
+ if t == "str":
1900
+ return isinstance(v, str) and len(v) <= 4000
1901
+ if t == "num":
1902
+ return isinstance(v, (int, float)) and not isinstance(v, bool) and v == v and abs(v) != float("inf")
1903
+ if t == "int":
1904
+ return isinstance(v, int) and not isinstance(v, bool)
1905
+ if t == "iso":
1906
+ return _valid_iso(v)
1907
+ if t == "keys":
1908
+ return isinstance(v, list) and len(v) <= 500 and all(isinstance(k, str) and len(k) <= 40 for k in v)
1909
+ if t == "any":
1910
+ return v is None or isinstance(v, bool) or _act_type_ok("num", v) or _act_type_ok("str", v) or _act_type_ok("ids", v)
1911
+ if t == "weights":
1912
+ return v is None or (isinstance(v, dict) and len(v) <= 20 and all(_act_type_ok("id", k) and _act_type_ok("int", x) for k, x in v.items()))
1913
+ return False
1914
+
1915
+
1916
+ def _valid_iso(v) -> bool:
1917
+ if not isinstance(v, str) or not re.match(r"^\d{4}-\d{2}-\d{2}$", v):
1918
+ return False
1919
+ try:
1920
+ return _dt.date.fromisoformat(v).isoformat() == v
1921
+ except ValueError:
1922
+ return False
1923
+
1924
+
1925
+ def check_action_op(o, at: str, ids: dict, made: dict) -> str:
1926
+ """Shape, types and references for one action op; '' when fine. made collects ids the change creates."""
1927
+ name, args = o.get("action"), o.get("args")
1928
+ if not isinstance(name, str) or name not in ACTIONS:
1929
+ return f'{at}: "{str(name)[:40]}" isn\'t an action (the context file lists them under actions)'
1930
+ extra = [k for k in o if k not in ("op", "action", "args")]
1931
+ if extra:
1932
+ return f"{at}: an action op has op, action and args (not {', '.join(str(k)[:20] for k in extra)})"
1933
+ if not isinstance(args, dict):
1934
+ return f"{at}: {name} needs args, an object"
1935
+ spec, has_prev = ACTIONS[name]
1936
+ for k in args:
1937
+ if k not in spec and not (k == "prev" and has_prev):
1938
+ return f"{at}: {name} has no argument called {str(k)[:30]}."
1939
+ for k, t in spec.items():
1940
+ opt, ty = t.endswith("?"), t.rstrip("?")
1941
+ if k not in args:
1942
+ if opt:
1943
+ continue
1944
+ return f"{at}: {name} needs {k}."
1945
+ if not _act_type_ok(ty, args[k]):
1946
+ return f"{at}: {k} isn't a valid {'date (YYYY-MM-DD)' if ty in ('iso', 'isoOrNull') else 'list' if ty in ('ids', 'keys') else 'id' if ty == 'id' else 'number' if ty in ('num', 'int') else 'split ({workstream id: weight})' if ty == 'weights' else 'value'}."
1947
+ noun, verb = name.split(".", 1)
1948
+ creates = verb == "add" and noun not in ("link",)
1949
+ if name == "jira.epicNew" and isinstance(args.get("id"), str) and args["id"]: # its id is optional: the page makes a stable one from the epic key
1950
+ made.setdefault("bar", set()).add(args["id"])
1951
+ if creates and "id" in spec and not (isinstance(args.get("id"), str) and args["id"]):
1952
+ return f"{at}: give the new item an id (like risk-vendor-limits), so reading the file again can't add it twice"
1953
+ for k, v in args.items():
1954
+ kind = ACT_REF_BY_NOUN.get(noun) if k == "id" else ACT_REF_BY_ARG.get(k)
1955
+ if name == "task.link" or name == "task.unlink":
1956
+ kind = "task" if k in ("id", "after") else kind
1957
+ if name in ("task.add", "task.moveTo") and k == "bar":
1958
+ kind = "bar"
1959
+ if name == "initiative.waitFor" and k == "gate":
1960
+ kind = "gate|block"
1961
+ if not kind or (k == "id" and (creates or name == "jira.epicNew")) or name.endswith(".remove") and k != "id" or name in ("link.remove", "initiative.unwait", "task.unlink", "milestone.doneWhen.remove", "workstream.removeMember", "outcome.unlinkJira") and k != "id":
1962
+ continue
1963
+ vals = v if isinstance(v, list) else [v]
1964
+ for x in vals:
1965
+ if x == "" or not isinstance(x, str):
1966
+ continue
1967
+ if not any(x in ids.get(kd, set()) or x in made.get(kd, set()) for kd in kind.split("|")):
1968
+ return f"{at}: {x[:60]} isn't in the plan (for {k})"
1969
+ if noun == "jira" and isinstance(args.get("keys"), list) and len({str(k).strip().upper() for k in args["keys"]}) > 100:
1970
+ return f"{at}: one change places up to 100 issues"
1971
+ if creates and isinstance(args.get("id"), str):
1972
+ made.setdefault(ACT_REF_BY_NOUN.get(noun, noun), set()).add(args["id"])
1973
+ return ""
1974
+
1975
+
1976
+ def _plan_ids(plan) -> dict:
1977
+ bars = [b for b in plan.get("bars") or [] if isinstance(b, dict)]
1978
+ pick = lambda key: {x.get("id") for x in plan.get(key) or [] if isinstance(x, dict)}
1979
+ return {"bar": {b.get("id") for b in bars}, "task": {t.get("id") for b in bars for t in b.get("tasks") or [] if isinstance(t, dict)},
1980
+ "dv": pick("deliverables"), "lane": pick("lanes"), "person": pick("people"), "ms": pick("milestones"), "gate": pick("gates"),
1981
+ "block": pick("blocks"), "risk": pick("risks"), "dec": pick("decisions"), "sprint": pick("sprints"), "skill": pick("skills"), "meeting": pick("meetings")}
1982
+
1983
+
1984
+ def _plan_start(plan) -> _dt.date:
1985
+ return _dt.date.fromisoformat((plan.get("meta") or {}).get("start"))
1986
+
1987
+
1988
+ def _week_iso(plan, w) -> str:
1989
+ # the page's Math.round: halves round up (Python's round() rounds them to even)
1990
+ return (_plan_start(plan) + _dt.timedelta(days=int((w * 7 + 0.5) // 1))).isoformat()
1991
+
1992
+
1993
+ def _iso_week(plan, iso) -> float:
1994
+ d = _dt.date.fromisoformat(iso)
1995
+ return ((d - _dt.timedelta(days=d.weekday())) - _plan_start(plan)).days / 7
1996
+
1997
+
1998
+ def _item_err(fields, field, v, plan) -> str:
1999
+ t = fields.get(field)
2000
+ if t is None:
2001
+ return f'"{str(field)[:30]}" can\'t be changed by an amendment'
2002
+ if not _plain(v) or v is None:
2003
+ return "" if field == "owners" and v is None else f"{field} can't be empty"
2004
+ if isinstance(t, int):
2005
+ return f"{field} must be text" if not isinstance(v, str) or not v.strip() else f"{field} is longer than {t} characters" if len(v) > t else ""
2006
+ if t == "status":
2007
+ return "" if v in STATUS_KEYS else "status must be " + ", ".join(STATUS_KEYS)
2008
+ if t == "people":
2009
+ ids = {p.get("id") for p in plan.get("people") or [] if isinstance(p, dict)}
2010
+ return "" if isinstance(v, list) and all(x in ids for x in v) else "owners must be ids of people in the plan"
2011
+ if t == "lane":
2012
+ return "" if any(isinstance(l, dict) and l.get("id") == v for l in plan.get("lanes") or []) else f'workstream "{str(v)[:40]}" isn\'t in the plan'
2013
+ weeks = (plan.get("meta") or {}).get("weeks") or 0
2014
+ if t == "start":
2015
+ if not _iso_ok(v):
2016
+ return "start must be YYYY-MM-DD"
2017
+ w = _iso_week(plan, v)
2018
+ return "start is outside the plan's dates" if w < 0 or w >= weeks else ""
2019
+ if t == "tstart":
2020
+ if not _iso_ok(v):
2021
+ return "start must be YYYY-MM-DD"
2022
+ d, a = _dt.date.fromisoformat(v), _plan_start(plan)
2023
+ return "start is outside the plan's dates" if d < a or (d - a).days > weeks * 7 else "start is a weekend" if d.weekday() >= 5 else ""
2024
+ if t == "weeks":
2025
+ return "" if isinstance(v, (int, float)) and not isinstance(v, bool) and 0.5 <= v <= 104 and round(v * 2) == v * 2 else "weeks must be a number from 0.5 to 104, in halves"
2026
+ if t == "days":
2027
+ return "" if isinstance(v, int) and not isinstance(v, bool) and 1 <= v <= 260 else "days must be a whole number from 1 to 260"
2028
+ return ""
2029
+
2030
+
2031
+ def _iso_ok(v) -> bool:
2032
+ if not isinstance(v, str) or not re.match(r"^\d{4}-\d{2}-\d{2}$", v):
2033
+ return False
2034
+ try:
2035
+ _dt.date.fromisoformat(v)
2036
+ return True
2037
+ except ValueError:
2038
+ return False
2039
+
2040
+
2041
+ def _plain(v) -> bool:
2042
+ return v is None or isinstance(v, (str, int, float, bool)) or (isinstance(v, list) and len(v) <= DV_MAX_KEYS and all(isinstance(x, str) and len(x) <= 120 for x in v))
2043
+
2044
+
2045
+ def _value_err(field, v, plan, status) -> str:
2046
+ t = AM_FIELDS.get(field)
2047
+ if t is None:
2048
+ return f'"{str(field)[:30]}" can\'t be changed by an amendment'
2049
+ if not _plain(v):
2050
+ return f"{field} must be plain text, a number, a short list or null"
2051
+ if v is None:
2052
+ return "a deliverable needs a name" if field == "name" else ""
2053
+ if isinstance(t, int):
2054
+ return f"{field} must be text" if not isinstance(v, str) or not v.strip() else f"{field} is longer than {t} characters" if len(v) > t else ""
2055
+ if t == "kind":
2056
+ return "" if v in ("new", "improved", "fixed") else "kind must be new, improved or fixed"
2057
+ if t == "stage":
2058
+ return "" if v in ("idea", "planned", "done", "dropped") else "stage must be idea, planned, done or dropped"
2059
+ if t == "target":
2060
+ return "" if isinstance(v, str) and (TARGET_RE.match(v) or v == "later") else "target must be like 2027, 2027-H1, 2027-Q2, or later"
2061
+ if t == "date":
2062
+ return "" if _iso_ok(v) else "date must be YYYY-MM-DD"
2063
+ if t == "release":
2064
+ return "" if any(str(r.get("id")) == str(v) for r in status.get("releases") or [] if isinstance(r, dict)) else f"release {str(v)[:30]} isn't in the last Jira sync"
2065
+ if t == "lane":
2066
+ return "" if any(isinstance(l, dict) and l.get("id") == v for l in plan.get("lanes") or []) else f'workstream "{str(v)[:40]}" isn\'t in the plan'
2067
+ if t == "people":
2068
+ ids = {p.get("id") for p in plan.get("people") or [] if isinstance(p, dict)}
2069
+ return "" if isinstance(v, list) and all(x in ids for x in v) else "owners must be ids of people in the plan"
2070
+ return ""
2071
+
2072
+
2073
+ def am_parse(text: str, plan: dict) -> tuple[dict | None, str]:
2074
+ if len(text.encode("utf-8")) > AM_MAX_BYTES:
2075
+ return None, f"The amendments file is over {AM_MAX_BYTES // 1024} KB."
2076
+ def no_constant(c):
2077
+ raise ValueError(f"{c} isn't allowed in JSON")
2078
+ try:
2079
+ # the page's JSON.parse refuses NaN and Infinity, so this does too
2080
+ o = json.loads(text, parse_constant=no_constant)
2081
+ except ValueError as e:
2082
+ return None, "The amendments file isn't valid JSON: " + str(e)[:120]
2083
+ if not isinstance(o, dict) or o.get("kind") != "sightline-amendments":
2084
+ return None, 'This isn\'t a Sightline amendments file: "kind" should be "sightline-amendments".'
2085
+ if o.get("version") != 1:
2086
+ return None, f"This amendments file is version {str(o.get('version'))[:10]}; this checker reads version 1."
2087
+ pid = (plan.get("meta") or {}).get("planId")
2088
+ if o.get("planId") != pid:
2089
+ return None, f"These amendments were written for another plan ({str(o.get('planId'))[:40]}); this plan is {pid}."
2090
+ if not AM_ID.match(str(o.get("batchId") or "")):
2091
+ return None, "batchId is missing, or has characters other than letters, digits, . _ and -."
2092
+ ch = o.get("changes")
2093
+ if not isinstance(ch, list):
2094
+ return None, "changes must be a list."
2095
+ if len(ch) > AM_MAX_CHANGES:
2096
+ return None, f"That's {len(ch)} changes; a file holds up to {AM_MAX_CHANGES}."
2097
+ ids = set()
2098
+ for c in ch:
2099
+ if not isinstance(c, dict) or not AM_ID.match(str(c.get("id") or "")):
2100
+ return None, "Every change needs an id (letters, digits, . _ and -)."
2101
+ if c["id"] in ids:
2102
+ return None, f'Two changes share the id "{c["id"]}".'
2103
+ ids.add(c["id"])
2104
+ ops = c.get("ops")
2105
+ if not isinstance(ops, list) or not ops or len(ops) > AM_MAX_OPS:
2106
+ return None, f'Change "{c["id"]}" needs 1 to {AM_MAX_OPS} ops.'
2107
+ if c.get("reason") is not None and not isinstance(c.get("reason"), str):
2108
+ return None, f'Change "{c["id"]}": reason is one line of text.'
2109
+ if c.get("confidence") is not None and c.get("confidence") not in ("high", "low"):
2110
+ return None, f'Change "{c["id"]}": confidence is "high" or "low".'
2111
+ return o, ""
2112
+
2113
+
2114
+ def _am_item(o, at, plan, made_bars, made_tasks) -> str:
2115
+ """One initiative or task op: "" (nothing to do), "todo", "stale:<why>", or an error. Mirrors the page."""
2116
+ is_in = o.get("type") == "initiative"
2117
+ fields, i = (AM_IN_FIELDS if is_in else AM_TK_FIELDS), str(o.get("id") or "")
2118
+ bars = [b for b in plan.get("bars") or [] if isinstance(b, dict)]
2119
+
2120
+ def bar_of(x):
2121
+ return made_bars.get(x) or next((b for b in bars if b.get("id") == x), None)
2122
+
2123
+ def task_of(x):
2124
+ if x in made_tasks:
2125
+ return made_tasks[x]
2126
+ for b in bars:
2127
+ for t in b.get("tasks") or []:
2128
+ if isinstance(t, dict) and t.get("id") == x:
2129
+ return t, b
2130
+ return None
2131
+
2132
+ def kids(b):
2133
+ return any(x.get("parent") == b.get("id") for x in bars)
2134
+ if o.get("op") == "add":
2135
+ v = o.get("value")
2136
+ if not (AM_BAR_ID if is_in else AM_TASK_ID).match(i):
2137
+ return f"{at}: a new {o.get('type')}'s id must start with {'bar-' if is_in else 'task-'} (letters, digits, _ and -)"
2138
+ if bar_of(i) or task_of(i):
2139
+ return f"{at}: the id {i} is already in the plan"
2140
+ if not isinstance(v, dict):
2141
+ return f"{at}: add needs a value object"
2142
+ allowed = list(AM_IN_FIELDS) if is_in else [*AM_TK_FIELDS, "initiative"]
2143
+ extra = [k for k in v if k not in allowed and k != "id"]
2144
+ if extra:
2145
+ return f"{at}: can't set " + ", ".join(k[:20] for k in extra)
2146
+ need = ["label", "lane", "start", "weeks"] if is_in else ["initiative", "label", "start", "days"]
2147
+ miss = [k for k in need if v.get(k) is None]
2148
+ if miss:
2149
+ return f"{at}: a new {o.get('type')} needs " + ", ".join(miss)
2150
+ for k, x in v.items():
2151
+ if k in ("id", "initiative"):
2152
+ continue
2153
+ e = _item_err(fields, k, x, plan)
2154
+ if e:
2155
+ return f"{at}: {e}"
2156
+ if is_in:
2157
+ made_bars[i] = {"id": i, "lane": v["lane"], "start": _iso_week(plan, v["start"]), "span": v["weeks"], "label": v["label"], "jira": "", "epics": [], "tasks": [],
2158
+ "status": v.get("status") or "notstarted", "owners": v.get("owners") or []}
2159
+ return "todo"
2160
+ b = bar_of(str(v.get("initiative")))
2161
+ if not b:
2162
+ return f"{at}: there's no initiative {str(v.get('initiative'))[:60]} in the plan"
2163
+ if kids(b):
2164
+ return f"{at}: {b.get('label')} holds other initiatives, so tasks go on those"
2165
+ made_tasks[i] = ({"id": i, "label": v["label"], "start": v["start"], "days": v["days"], "status": v.get("status") or "notstarted", "owners": v.get("owners") or []}, b)
2166
+ return "todo"
2167
+ if o.get("op") != "set":
2168
+ return f'{at}: an {o.get("type")} can be added or set (not "{str(o.get("op"))[:20]}")'
2169
+ if is_in:
2170
+ hit, host = bar_of(i), None
2171
+ else:
2172
+ ht = task_of(i)
2173
+ hit, host = (ht[0], ht[1]) if ht else (None, None)
2174
+ if not hit:
2175
+ return f"{at}: there's no {o.get('type')} {i[:60]} in the plan"
2176
+ f = str(o.get("field") or "")
2177
+ e = _item_err(fields, f, o.get("value"), plan)
2178
+ if e:
2179
+ return f"{at}: {e}"
2180
+ jl = bool(hit.get("jira") or (is_in and hit.get("epics")))
2181
+ name = hit.get("label") or ("Task" if not is_in else "Untitled item")
2182
+ if jl and f in ("status", "start", "weeks", "days"):
2183
+ return f"{at}: {name}: {'its status comes' if f == 'status' else 'its dates come'} from Jira: propose it in Jira instead"
2184
+ if is_in and f in ("start", "weeks") and (kids(hit) or (hit.get("tasks") and hit.get("datesFrom") == "tasks")):
2185
+ return f"{at}: {name}: its dates come from " + ("the initiatives inside it" if kids(hit) else "its tasks")
2186
+ if is_in and f == "status" and (hit.get("tasks") or kids(hit)):
2187
+ return f"{at}: {name}: its status comes from " + ("the initiatives inside it" if kids(hit) else "its tasks")
2188
+ if "prev" not in o:
2189
+ return f"{at}: a set needs prev, the value it expects now"
2190
+ if not _plain(o["prev"]):
2191
+ return f"{at}: prev must be plain text, a number, a short list or null"
2192
+ if is_in:
2193
+ now = _week_iso(plan, hit.get("start") or 0) if f == "start" else hit.get("span") if f == "weeks" else (hit.get("owners") or []) if f == "owners" else hit.get(f)
2194
+ else:
2195
+ now = (hit.get("owners") or []) if f == "owners" else hit.get(f)
2196
+ if now == o.get("value"):
2197
+ return ""
2198
+ if now != o["prev"]:
2199
+ return f"stale:Changed since: {name}'s {f} was expected to be {o['prev']!r}; it's now {now!r}."
2200
+ if i in made_bars or i in made_tasks:
2201
+ if is_in and f == "start":
2202
+ hit["start"] = _iso_week(plan, o["value"])
2203
+ elif is_in and f == "weeks":
2204
+ hit["span"] = o["value"]
2205
+ else:
2206
+ hit[f] = o["value"]
2207
+ return ""
2208
+ return "todo"
2209
+
2210
+
2211
+ def am_check(c: dict, plan: dict, status: dict, earlier: dict | None = None) -> dict:
2212
+ """One change against the plan: {status: ready|changed|invalid|done, reasons, notes}. Mirrors the page's amCheck.
2213
+ earlier: ids that earlier changes in the batch add, by kind; an action may refer to them (the page applies in order)."""
2214
+ bad, stale, notes, made, todo, made_bars, made_tasks = [], [], [], {}, 0, {}, {}
2215
+ dvs = [d for d in plan.get("deliverables") or [] if isinstance(d, dict)]
2216
+ issues = status.get("issues") or {}
2217
+
2218
+ def dv(i):
2219
+ return made.get(i) or next((d for d in dvs if d.get("id") == i), None)
2220
+
2221
+ plan_ids, act_made = _plan_ids(plan), {k: set(v) for k, v in (earlier or {}).items()}
2222
+ for n, o in enumerate(c["ops"], 1):
2223
+ at = f"op {n}"
2224
+ if not isinstance(o, dict):
2225
+ bad.append(f"{at} isn't an object")
2226
+ continue
2227
+ if o.get("op") == "action":
2228
+ why = check_action_op(o, at, plan_ids, act_made)
2229
+ if why:
2230
+ bad.append(why)
2231
+ else:
2232
+ todo += 1
2233
+ notes.append("Sightline checks each action's own rules (loops, Jira dates, blacked-out time) when it reads the file.")
2234
+ continue
2235
+ if o.get("op") == "propose":
2236
+ k, f = str(o.get("key") or "").upper(), o.get("field")
2237
+ if not KEY_RE.match(k):
2238
+ bad.append(f'{at}: "{str(o.get("key"))[:30]}" isn\'t a Jira key')
2239
+ continue
2240
+ if f not in PROPOSAL_FIELDS:
2241
+ bad.append(f'{at}: Jira field "{str(f)[:20]}" can\'t be proposed (use {", ".join(PROPOSAL_FIELDS)})')
2242
+ continue
2243
+ v = o.get("value")
2244
+ if isinstance(v, bool) or not isinstance(v, (str, int, float)) or len(str(v)) > 120:
2245
+ bad.append(f"{at}: the proposed value must be short text or a number")
2246
+ continue
2247
+ if f == "sprint" and not any(str(x.get("id")) == str(v) or x.get("name") == v for x in status.get("sprints") or [] if isinstance(x, dict)):
2248
+ bad.append(f'{at}: sprint "{str(v)[:40]}" isn\'t in the last Jira sync')
2249
+ continue
2250
+ if f == "assignee" and not any(isinstance(j, dict) and v in (j.get("assigneeKey"), j.get("assigneeUser"), j.get("assignee")) and j.get("assigneeKey") for j in issues.values()):
2251
+ bad.append(f'{at}: "{str(v)[:40]}" isn\'t a Jira user in the last sync')
2252
+ continue
2253
+ if f == "points" and (isinstance(v, bool) or not isinstance(v, (int, float)) or v != v or v in (float("inf"), float("-inf"))):
2254
+ bad.append(f"{at}: points must be a number")
2255
+ continue
2256
+ if k not in issues:
2257
+ notes.append(f"{k} isn't in the last Jira sync")
2258
+ if f == "sprint":
2259
+ sp = next(x for x in status.get("sprints") or [] if isinstance(x, dict) and (str(x.get("id")) == str(v) or x.get("name") == v))
2260
+ same = lambda x: isinstance(x, dict) and x.get("id") == sp.get("id")
2261
+ elif f == "assignee":
2262
+ key = next(j.get("assigneeKey") for j in issues.values() if isinstance(j, dict) and v in (j.get("assigneeKey"), j.get("assigneeUser"), j.get("assignee")) and j.get("assigneeKey"))
2263
+ same = lambda x: isinstance(x, dict) and x.get("key") == key
2264
+ else:
2265
+ same = lambda x: not isinstance(x, dict) and str("" if x is None else x) == str(v)
2266
+ if any(isinstance(x, dict) and x.get("key") == k and x.get("field") == f and same(x.get("value")) and (x.get("review") or {}).get("state") == "proposed" for x in plan.get("proposals") or []):
2267
+ continue
2268
+ todo += 1
2269
+ continue
2270
+ if o.get("type") in ("initiative", "task"):
2271
+ e = _am_item(o, at, plan, made_bars, made_tasks)
2272
+ if e == "todo":
2273
+ todo += 1
2274
+ elif e.startswith("stale:"):
2275
+ stale.append(e[6:])
2276
+ elif e:
2277
+ bad.append(e)
2278
+ continue
2279
+ if o.get("type") != "deliverable":
2280
+ bad.append(f'{at}: amendments cover deliverables, initiatives and tasks (type "deliverable", "initiative" or "task")')
2281
+ continue
2282
+ i = str(o.get("id") or "")
2283
+ if o.get("op") == "add":
2284
+ v = o.get("value")
2285
+ if not AM_DV_ID.match(i):
2286
+ bad.append(f"{at}: a new deliverable's id must start with dv- (letters, digits, _ and -)")
2287
+ elif i in made:
2288
+ bad.append(f"{at}: {i} is added twice in this change")
2289
+ elif not isinstance(v, dict):
2290
+ bad.append(f"{at}: add needs a value object")
2291
+ elif [k for k in v if k not in ("id", "jira") and k not in AM_FIELDS]:
2292
+ bad.append(f"{at}: can't set " + ", ".join(k[:20] for k in v if k not in ("id", "jira") and k not in AM_FIELDS))
2293
+ elif v.get("id") is not None and v.get("id") != i:
2294
+ bad.append(f"{at}: value.id doesn't match id")
2295
+ else:
2296
+ err = ""
2297
+ for k, x in v.items():
2298
+ if k == "id":
2299
+ continue
2300
+ if k == "jira":
2301
+ if not isinstance(x, list) or len(x) > DV_MAX_KEYS or not all(isinstance(y, str) and KEY_RE.match(y.upper()) for y in x):
2302
+ err = f"{at}: jira must be a list of up to {DV_MAX_KEYS} Jira keys"
2303
+ break
2304
+ continue
2305
+ e = _value_err(k, x, plan, status)
2306
+ if e:
2307
+ err = f"{at}: {e}"
2308
+ break
2309
+ if not err and not v.get("name"):
2310
+ err = f"{at}: a deliverable needs a name"
2311
+ if err:
2312
+ bad.append(err)
2313
+ continue
2314
+ d = {"id": i, "stage": "idea", **{k: ([y.upper() for y in x] if k == "jira" else x) for k, x in v.items() if k != "id" and x is not None}}
2315
+ cur = next((x for x in dvs if x.get("id") == i), None)
2316
+ if cur:
2317
+ same = all((k == "stage" and "stage" not in v) or cur.get(k) == d[k] for k in d)
2318
+ if not same:
2319
+ bad.append(f'{at}: the id {i} is already used by "{cur.get("name")}" with other details')
2320
+ continue
2321
+ made[i] = d
2322
+ todo += 1
2323
+ continue
2324
+ d = dv(i)
2325
+ if not d:
2326
+ bad.append(f"{at}: there's no deliverable {i[:60]} in the plan")
2327
+ continue
2328
+ if o.get("op") == "set":
2329
+ f = str(o.get("field") or "")
2330
+ if f == "jira":
2331
+ bad.append(f"{at}: use link or unlink for Jira work")
2332
+ continue
2333
+ e = _value_err(f, o.get("value"), plan, status)
2334
+ if e:
2335
+ bad.append(f"{at}: {e}")
2336
+ continue
2337
+ if "prev" not in o:
2338
+ bad.append(f"{at}: a set needs prev, the value it expects now (null when it's empty)")
2339
+ continue
2340
+ if not _plain(o["prev"]):
2341
+ bad.append(f"{at}: prev must be plain text, a number, a short list or null")
2342
+ continue
2343
+ cur, want = d.get(f), o.get("value")
2344
+ if cur == want:
2345
+ continue
2346
+ if cur != o["prev"]:
2347
+ stale.append(f"Changed since: {d.get('name') or i}'s {f} was expected to be {o['prev']!r}; it's now {cur!r}.")
2348
+ continue
2349
+ todo += 1
2350
+ if i in made:
2351
+ if want is None:
2352
+ d.pop(f, None)
2353
+ else:
2354
+ d[f] = want
2355
+ continue
2356
+ if o.get("op") in ("link", "unlink"):
2357
+ ks = [str(k).upper() for k in o.get("jira")] if isinstance(o.get("jira"), list) else []
2358
+ if not ks or len(ks) > DV_MAX_KEYS or not all(KEY_RE.match(k) for k in ks):
2359
+ bad.append(f"{at}: {o['op']} needs jira, a list of Jira keys")
2360
+ continue
2361
+ have = set(d.get("jira") or [])
2362
+ change = [k for k in ks if (k not in have) == (o["op"] == "link")]
2363
+ if o["op"] == "link" and len(have) + len(change) > DV_MAX_KEYS:
2364
+ bad.append(f"{at}: that would link {len(have) + len(change)} Jira issues; a deliverable links up to {DV_MAX_KEYS}")
2365
+ continue
2366
+ notes.extend(f"{k} isn't in the last Jira sync" for k in ks if k not in issues)
2367
+ if change:
2368
+ todo += 1
2369
+ continue
2370
+ bad.append(f'{at}: "{str(o.get("op"))[:20]}" isn\'t an amendment (use add, set, link, unlink, propose or action)')
2371
+ st = "invalid" if bad else "changed" if stale else "ready" if todo else "done"
2372
+ if earlier is not None and st != "invalid":
2373
+ for k, v in act_made.items():
2374
+ earlier.setdefault(k, set()).update(v)
2375
+ return {"id": c["id"], "summary": str(c.get("summary") or "")[:200], "status": st, "reasons": bad + stale, "notes": list(dict.fromkeys(notes))}
2376
+
2377
+
2378
+ # What this offline check covers, and what only the page can check (it has the live plan, Jira and the person's choices).
2379
+ AM_CHECKED = ["the file's shape and ids", "action names and their arguments", "that every id an action uses is in the plan or added by an earlier change in this batch",
2380
+ "deliverable, initiative and task fields, values and prev"]
2381
+ AM_NOT_CHECKED = ["each action's own rules (loops, Jira-owned dates and status, blacked-out time, limits)", "prev for actions",
2382
+ "what an earlier change in this batch sets", "review and apply-on-its-own holds", "unsaved edits in the open page"]
2383
+
2384
+
2385
+ def semver(v) -> tuple:
2386
+ m = re.match(r"^(\d+)\.(\d+)\.(\d+)$", str(v or ""))
2387
+ return tuple(int(x) for x in m.groups()) if m else ()
2388
+
2389
+
2390
+ def version_gate(plan: dict, amendments_path: str, context_path: str | None) -> None:
2391
+ """Refuse when the page this copy matches isn't the one that wrote the context, or the plan is newer than it."""
2392
+ if not PAGE_VERSION:
2393
+ return
2394
+ ctx = context_path or os.path.join(os.path.dirname(os.path.abspath(amendments_path)), CONTEXT_FILE)
2395
+ try:
2396
+ with open(ctx, encoding="utf-8") as f:
2397
+ found = (json.load(f) or {}).get("appVersion")
2398
+ except (OSError, ValueError, AttributeError):
2399
+ found = None
2400
+ if found and found != PAGE_VERSION:
2401
+ raise SystemExit(f"This sightline-jira-sync.py matches Sightline {PAGE_VERSION}, but {os.path.basename(ctx)} was written by Sightline {found}. "
2402
+ "Its actions may differ, so nothing was checked. Use the scripts published beside that page (their .sha256 files say whether the copies are intact).")
2403
+ saved = (plan.get("meta") or {}).get("savedWith")
2404
+ if semver(saved) and semver(saved) > semver(PAGE_VERSION):
2405
+ raise SystemExit(f"This sightline-jira-sync.py matches Sightline {PAGE_VERSION}, but the plan was saved with Sightline {saved}. "
2406
+ "Nothing was checked. Use the scripts published beside the newer page.")
2407
+
2408
+
2409
+ def cmd_check_amendments(args) -> None:
2410
+ plan_path = os.path.abspath(args.plan)
2411
+ check_plan(plan_path, args.plan)
2412
+ try:
2413
+ with open(plan_path, encoding="utf-8") as f:
2414
+ plan = json.load(f)
2415
+ except (OSError, ValueError) as e:
2416
+ raise SystemExit(f"Can't read the plan {args.plan}: {e}")
2417
+ check_plan_format(plan)
2418
+ version_gate(plan, args.amendments, args.context)
2419
+ try:
2420
+ with open(args.amendments, encoding="utf-8") as f:
2421
+ text = f.read(AM_MAX_BYTES + 1)
2422
+ except OSError as e:
2423
+ raise SystemExit(f"Can't read the amendments file {args.amendments}: {e}")
2424
+ status = prev_status(args.status or os.path.join(jira_folder(plan_path, None), "status.json"))
2425
+ o, err = am_parse(text, plan)
2426
+ if err:
2427
+ if args.json:
2428
+ print(json.dumps({"ok": False, "error": err}, indent=2))
2429
+ else:
2430
+ print("Can't use this file: " + err)
2431
+ raise SystemExit(2)
2432
+ earlier: dict = {}
2433
+ rows = [am_check(c, plan, status, earlier) for c in o["changes"]]
2434
+ counts = {k: sum(1 for r in rows if r["status"] == k) for k in ("ready", "changed", "invalid", "done")}
2435
+ if args.json:
2436
+ print(json.dumps({"ok": counts["invalid"] == 0, "matchesPage": PAGE_VERSION, "batchId": o["batchId"], "counts": counts, "changes": rows,
2437
+ "checked": AM_CHECKED, "notCheckedHere": AM_NOT_CHECKED, "checkedWhen": "Sightline checks everything again, in order, when it applies the batch.",
2438
+ "jiraSyncedAt": status.get("generatedAt"), "planRevision": (plan.get("meta") or {}).get("revision", 0)}, indent=2))
2439
+ else:
2440
+ label = {"ready": "READY ", "changed": "CHANGED", "invalid": "INVALID", "done": "DONE "}
2441
+ print(f"Checked with the rules of Sightline {PAGE_VERSION}." if PAGE_VERSION else "Checked with the rules in this source copy (not stamped with a page version).")
2442
+ for r in rows:
2443
+ print(f"{label[r['status']]} {r['id']}" + (f" {r['summary']}" if r["summary"] else ""))
2444
+ for x in r["reasons"]:
2445
+ print(f" {x}")
2446
+ for x in r["notes"]:
2447
+ print(f" note: {x}")
2448
+ print(f"{counts['ready']} ready, {counts['changed']} changed since, {counts['invalid']} invalid, {counts['done']} already in the plan."
2449
+ + ("" if status else " No Jira sync found, so releases, sprints and assignees can't be checked."))
2450
+ print("Checked here: " + "; ".join(AM_CHECKED) + ".")
2451
+ print("Not checked here: " + "; ".join(AM_NOT_CHECKED) + ". Sightline checks those when it applies the batch, in order.")
2452
+ raise SystemExit(1 if counts["invalid"] else 0)
2453
+
2454
+
2455
+ def main(argv=None) -> None:
2456
+ ap = argparse.ArgumentParser(description="Sync Jira Data Center status into a Sightline plan (read-only).")
2457
+ ap.add_argument("--version", action="version", version=f"sightline-jira-sync.py for Sightline {PAGE_VERSION}" if PAGE_VERSION else "sightline-jira-sync.py (source copy, not stamped with a page version)")
2458
+ sub = ap.add_subparsers(dest="cmd", required=True)
2459
+ su = sub.add_parser("setup", help="add a Jira server: its address and your personal access token")
2460
+ su.add_argument("--jira", metavar="URL", help="the Jira address (asked if left out)")
2461
+ su.set_defaults(fn=cmd_setup)
2462
+ fg = sub.add_parser("forget", help="remove a Jira server's stored token")
2463
+ fg.add_argument("--jira", metavar="URL")
2464
+ fg.set_defaults(fn=cmd_forget)
2465
+ fl = sub.add_parser("fields", help="find your Jira's Epic Link, Sprint and Story Points fields")
2466
+ fl.add_argument("--jira", metavar="URL", help="which Jira, if you've set up more than one")
2467
+ fl.add_argument("--set", action="append", metavar="ROLE=customfield_NNN", help="set one yourself (repeatable)")
2468
+ fl.set_defaults(fn=cmd_fields)
2469
+ ins = sub.add_parser("inspect", help="print your Jira's statuses, resolutions, a board's sprints and sample issues")
2470
+ ins.add_argument("--jira", metavar="URL", help="which Jira, if you've set up more than one")
2471
+ ins.add_argument("--board", metavar="N", help="also list this Scrum board's sprints")
2472
+ ins.add_argument("--jql", metavar="SEARCH", help="also show a few issues from this search, with their raw field values")
2473
+ ins.add_argument("--sample", type=int, default=3, metavar="N", help="how many sample issues (default 3, at most 10)")
2474
+ ins.set_defaults(fn=cmd_inspect)
2475
+ s = sub.add_parser("sync", help="write the plan's Jira files into a jira folder beside it")
2476
+ s.add_argument("plan", help="the Sightline plan file (.json)")
2477
+ s.add_argument("--jira", metavar="URL", help="which Jira, if you've set up more than one")
2478
+ s.add_argument("--save", action="store_true", help="remember this plan's settings on this computer (for sync with no options, and watch)")
2479
+ s.add_argument("--accept-drop", action="store_true", help="write this sync even if it returns far fewer of the plan's Jira items than the last one")
2480
+ s.add_argument("--out", help="where to write the status file (default: jira/status.json beside the plan)")
2481
+ s.add_argument("--every", type=int, default=0, metavar="MINUTES", help="keep syncing every N minutes (5 or more)")
2482
+ g = s.add_mutually_exclusive_group()
2483
+ g.add_argument("--jql", metavar="SEARCH", help="also list the issues this Jira search finds")
2484
+ g.add_argument("--filter", metavar="ID", help="also list the issues in this saved Jira filter (its number)")
2485
+ s.add_argument("--search", action="append", metavar='"NAME=JQL"', help="a named search, repeatable: each shows as its own list in the Jira window")
2486
+ s.add_argument("--board", metavar="N", help="also read this Scrum board's sprints (the rapidView number), so the plan's sprints can follow Jira")
2487
+ s.add_argument("--folder", metavar="NAME", help="where the Jira files go (default: a jira folder beside the plan)")
2488
+ s.add_argument("--issues-out", metavar="PATH", help="where to write the search's issues (default: jira/issues.json beside the plan)")
2489
+ s.add_argument("--limit", type=int, default=None, metavar="N", help="most issues to keep from the search (default 500, at most 2000)")
2490
+ s.set_defaults(fn=cmd_sync)
2491
+ w = sub.add_parser("watch", help="keep saved plans in sync: on a schedule, and soon after a plan is saved")
2492
+ w.add_argument("plans", nargs="*", help="which saved plans (default: all of them)")
2493
+ w.set_defaults(fn=cmd_watch)
2494
+ i = sub.add_parser("install", help="use your own copy of this script, and start watch when you sign in")
2495
+ i.add_argument("--no-startup", action="store_true", help="just copy the script; don't start it at sign-in")
2496
+ i.set_defaults(fn=cmd_install)
2497
+ sub.add_parser("uninstall", help="stop the sync starting when you sign in").set_defaults(fn=cmd_uninstall)
2498
+ ca = sub.add_parser("check-amendments", help="check an assistant's amendments file against the plan, offline (the page checks again when you apply)")
2499
+ ca.add_argument("plan", help="the plan file (.json)")
2500
+ ca.add_argument("amendments", help="the amendments file your assistant wrote")
2501
+ ca.add_argument("--status", metavar="PATH", help="the Jira status file (default: jira/status.json beside the plan)")
2502
+ ca.add_argument("--json", action="store_true", help="print the result as JSON, for your assistant")
2503
+ ca.add_argument("--context", metavar="PATH", help=f"the page's assistant context (default: {CONTEXT_FILE} beside the amendments file)")
2504
+ ca.set_defaults(fn=cmd_check_amendments)
2505
+ args = ap.parse_args(argv)
2506
+ try:
2507
+ args.fn(args)
2508
+ except KeyboardInterrupt:
2509
+ print("\nStopped.")
2510
+
2511
+
2512
+ if __name__ == "__main__":
2513
+ main()