@troylar/sightline 0.0.0-stage → 1.120.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1890 -0
- package/CONTRIBUTING.md +67 -0
- package/LICENSE +21 -0
- package/README.md +153 -2
- package/THIRD-PARTY-NOTICES.md +121 -0
- package/bin/sightline.js +78 -0
- package/dist/sightline-app.py +406 -0
- package/dist/sightline-assistant.py +551 -0
- package/dist/sightline-jira-sync.py +2513 -0
- package/dist/sightline.html +14141 -0
- package/docs/assistant-workflow.md +472 -0
- package/docs/availability.md +54 -0
- package/docs/bundled-skills.md +46 -0
- package/docs/getting-started.md +99 -0
- package/docs/images/assistant-dark.png +0 -0
- package/docs/images/assistant-light.png +0 -0
- package/docs/images/bundled-skills-dark.png +0 -0
- package/docs/images/bundled-skills-light.png +0 -0
- package/docs/images/planning-decisions-dark.svg +46 -0
- package/docs/images/planning-decisions-light.svg +46 -0
- package/docs/images/planning-decisions-mobile-dark.svg +40 -0
- package/docs/images/planning-decisions-mobile-light.svg +40 -0
- package/docs/images/planning-story-dark.svg +32 -0
- package/docs/images/planning-story-light.svg +32 -0
- package/docs/images/planning-story-mobile-dark.svg +33 -0
- package/docs/images/planning-story-mobile-light.svg +33 -0
- package/docs/images/review-dark.png +0 -0
- package/docs/images/review-light.png +0 -0
- package/docs/images/roadmap-dark.png +0 -0
- package/docs/images/roadmap-light.png +0 -0
- package/docs/images/roadmap-map-dark.svg +53 -0
- package/docs/images/roadmap-map-light.svg +53 -0
- package/docs/images/roadmap-map-mobile-dark.svg +37 -0
- package/docs/images/roadmap-map-mobile-light.svg +37 -0
- package/docs/images/timeline-dark.png +0 -0
- package/docs/images/timeline-light.png +0 -0
- package/docs/images/today-dark.png +0 -0
- package/docs/images/today-light.png +0 -0
- package/docs/jira.md +66 -0
- package/docs/large-roadmaps.md +93 -0
- package/docs/onedrive.md +62 -0
- package/docs/performance-workspaces.md +39 -0
- package/docs/roadmap-planning-example.md +92 -0
- package/docs/roadmap.md +232 -0
- package/docs/screenshots.md +57 -0
- package/docs/skill-evaluation.md +71 -0
- package/docs/skills.md +85 -0
- package/docs/team-planning.md +66 -0
- package/docs/usability-test.md +215 -0
- package/docs/workspace-validation.md +152 -0
- package/docs/workspaces.md +106 -0
- package/examples/northstar/blank-plan.json +21 -0
- package/examples/northstar/brain-dump.txt +1 -0
- package/examples/northstar/jira/issues.json +1256 -0
- package/examples/northstar/jira/status.json +1542 -0
- package/examples/northstar/plan.json +341 -0
- package/examples/northstar/roadmap-session-amendments.json +42 -0
- package/examples/northstar/sightline-amendments.json +41 -0
- package/package.json +53 -4
- package/skills/sightline-edit-plan/SKILL.md +35 -0
- package/skills/sightline-meeting-to-plan/SKILL.md +34 -0
- package/skills/sightline-plan-roadmap/SKILL.md +34 -0
- package/skills/sightline-planning-guide/SKILL.md +29 -0
- package/skills/sightline-review-plan/SKILL.md +28 -0
- package/skills/sightline-sort-jira/SKILL.md +56 -0
- 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.3"
|
|
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()
|