@troylar/sightline 1.120.3 → 1.175.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +824 -0
- package/CONTRIBUTING.md +5 -0
- package/dist/sightline-app.py +16 -4
- package/dist/sightline-jira-sync.py +122 -11
- package/dist/sightline.html +3154 -449
- package/docs/assistant-workflow.md +113 -3
- package/docs/bundled-skills.md +1 -1
- package/docs/getting-started.md +3 -3
- package/docs/jira.md +8 -0
- package/docs/onedrive.md +2 -1
- package/docs/performance-workspaces.md +77 -0
- package/docs/team-planning.md +34 -0
- package/docs/workspaces.md +101 -15
- package/package.json +5 -3
- package/skills/sightline-edit-plan/SKILL.md +5 -2
- package/skills/sightline-meeting-to-plan/SKILL.md +5 -2
- package/skills/sightline-plan-roadmap/SKILL.md +1 -1
- package/skills/sightline-review-plan/SKILL.md +2 -0
- package/skills/sightline-sort-jira/SKILL.md +2 -1
- package/skills/sightline-status/SKILL.md +2 -0
package/CONTRIBUTING.md
CHANGED
|
@@ -10,10 +10,15 @@ on Windows use a Bash environment with those commands available.
|
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
12
|
npm ci
|
|
13
|
+
npx playwright install chromium
|
|
13
14
|
./build.sh
|
|
14
15
|
./test/run.sh
|
|
15
16
|
```
|
|
16
17
|
|
|
18
|
+
The suite includes five layout checks in a real browser, and it fails if Playwright isn't installed, so a local pass
|
|
19
|
+
means the same as CI's. To skip them on purpose, run `SIGHTLINE_NO_BROWSER=1 ./test/run.sh`; the run still says they
|
|
20
|
+
were skipped.
|
|
21
|
+
|
|
17
22
|
Edit **src/sightline.template.html**, never generated **dist/** files. Build before testing: harnesses execute
|
|
18
23
|
the built HTML. See AGENTS.md for focused checks, deterministic dates and the exact runner requirements.
|
|
19
24
|
|
package/dist/sightline-app.py
CHANGED
|
@@ -28,7 +28,7 @@ import time
|
|
|
28
28
|
|
|
29
29
|
# The Sightline page this copy matches. build.sh stamps it into the copy it publishes beside the page; None means this
|
|
30
30
|
# runs from source, where nothing is stamped and nothing is compared.
|
|
31
|
-
PAGE_VERSION = "1.
|
|
31
|
+
PAGE_VERSION = "1.175.0"
|
|
32
32
|
CONTEXT_FILE = "sightline-assistant-view.json"
|
|
33
33
|
AMENDMENTS_FILE = "sightline-amendments.json"
|
|
34
34
|
RESULTS_FILE = "sightline-assistant-results.json"
|
|
@@ -208,7 +208,7 @@ def check_offline(folder: Path, batch: dict) -> list:
|
|
|
208
208
|
bad.append(f"{where}: {k} isn't a valid {'date (YYYY-MM-DD)' if t.startswith('iso') else t.rstrip('?')}.")
|
|
209
209
|
if name.endswith(".add") and "id" in spec and not (isinstance(args.get("id"), str) and args["id"]):
|
|
210
210
|
bad.append(f"{where}: give the new item an id (like risk-vendor-limits), so reading the file again can't add it twice.")
|
|
211
|
-
if str(name).startswith("jira."):
|
|
211
|
+
if str(name).startswith("jira.") and name not in ("jira.hide", "jira.unhide"): # hiding isn't a placement
|
|
212
212
|
bad.extend(f"{where}: {x}" for x in check_jira_place(ctx.get("jiraSort") or {}, name, args))
|
|
213
213
|
return bad
|
|
214
214
|
|
|
@@ -223,21 +223,33 @@ def check_jira_place(sort: dict, name: str, args: dict) -> list:
|
|
|
223
223
|
pile = {up(x.get("key")): x for x in sort.get("issues") or [] if isinstance(x, dict)}
|
|
224
224
|
placed = {up(k): v for k, v in (sort.get("placed") or {}).items() if isinstance(v, dict)}
|
|
225
225
|
epics = {up(x.get("key")) for x in sort.get("epics") or [] if isinstance(x, dict)}
|
|
226
|
+
epic_on = {up(x.get("key")): (x.get("state"), x.get("on")) for x in sort.get("epics") or [] if isinstance(x, dict)}
|
|
226
227
|
targets = sort.get("targets") or {}
|
|
227
|
-
|
|
228
|
+
lane_of = {} # a workstream's id, or a former id it keeps (was) -> its id now
|
|
229
|
+
for x in targets.get("workstreams") or []:
|
|
230
|
+
if isinstance(x, dict) and isinstance(x.get("id"), str):
|
|
231
|
+
lane_of[x["id"]] = x["id"]
|
|
232
|
+
for o in x.get("was") if isinstance(x.get("was"), list) else []:
|
|
233
|
+
lane_of.setdefault(o, x["id"])
|
|
234
|
+
lanes = set(lane_of)
|
|
228
235
|
bars = {x.get("id"): x for x in targets.get("initiatives") or [] if isinstance(x, dict)}
|
|
229
236
|
bad = []
|
|
230
237
|
if name in ("jira.epicLink", "jira.epicNew"):
|
|
231
238
|
epic = up(args.get("epic"))
|
|
232
239
|
if epic not in epics:
|
|
233
240
|
bad.append(f"{epic[:40] or 'epic'} isn't an epic in jiraSort.epics.")
|
|
241
|
+
state, on = epic_on.get(epic, (None, None))
|
|
242
|
+
lane_on = next((x.get("lane") for x in targets.get("initiatives") or [] if isinstance(x, dict) and x.get("id") == on), None)
|
|
243
|
+
here = state == "on" and (on == args.get("id") if name == "jira.epicLink" else lane_on is not None and lane_on == args.get("lane") and args.get("id") in (None, "", on)) # already there: done, as the page says
|
|
244
|
+
if on and not here and (name == "jira.epicNew" or state == "on"): # one initiative per epic; a parked one moves on a link
|
|
245
|
+
bad.append(f"{epic[:40]} is already on {str(on)[:40]} (jiraSort.epics), so it wouldn't be moved.")
|
|
234
246
|
else:
|
|
235
247
|
keys = list(dict.fromkeys(up(k) for k in args.get("keys"))) if isinstance(args.get("keys"), list) else []
|
|
236
248
|
if not keys:
|
|
237
249
|
bad.append("list at least one Jira key in keys.")
|
|
238
250
|
if len(keys) > JIRA_KEYS_MAX:
|
|
239
251
|
bad.append(f"one change places up to {JIRA_KEYS_MAX} issues.")
|
|
240
|
-
dest = ("notOurs", None) if name == "jira.notOurs" else ("lane", args.get("lane")) if name == "jira.toWorkstream" else ("initiative", args.get("id"))
|
|
252
|
+
dest = ("notOurs", None) if name == "jira.notOurs" else ("lane", lane_of.get(args.get("lane"), args.get("lane"))) if name == "jira.toWorkstream" else ("initiative", args.get("id"))
|
|
241
253
|
for k in keys:
|
|
242
254
|
if k in pile:
|
|
243
255
|
continue
|
|
@@ -71,7 +71,7 @@ BATCH = 50 # keys per JQL qu
|
|
|
71
71
|
MAX_PLAN_BYTES = 5 * 1024 * 1024
|
|
72
72
|
# The Sightline page this copy matches. build.sh stamps it into the copy it publishes beside the page; None means this
|
|
73
73
|
# runs from source, where nothing is stamped and nothing is compared.
|
|
74
|
-
PAGE_VERSION = "1.
|
|
74
|
+
PAGE_VERSION = "1.175.0"
|
|
75
75
|
CONTEXT_FILE = "sightline-assistant-view.json"
|
|
76
76
|
PLAN_SCHEMA = 2 # the plan format this script reads; must equal SCHEMA in the page (a test checks)
|
|
77
77
|
STATUS_FORMAT = 1 # the formats this script writes; the page refuses files newer than it knows (a test checks)
|
|
@@ -1809,21 +1809,25 @@ ACTIONS = {
|
|
|
1809
1809
|
"initiative.setScope": ({"id": "id", "scope": "str"}, True),
|
|
1810
1810
|
"initiative.delivers.add": ({"id": "id", "outcome": "id"}, False),
|
|
1811
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),
|
|
1812
|
+
"task.add": ({"id": "id?", "bar": "id", "start": "iso?", "after": "ids?", "days": "int?", "rough": "bool?", "label": "str?", "owners": "ids?", "tags": "ids?", "story": "str?"}, False),
|
|
1813
1813
|
"task.set": ({"id": "id", "field": "str", "value": "any"}, True),
|
|
1814
1814
|
"task.setStatus": ({"id": "id", "status": "str"}, True),
|
|
1815
|
-
"task.move": ({"id": "id", "start": "
|
|
1815
|
+
"task.move": ({"id": "id", "start": "isoOrAfter?", "days": "int?"}, True),
|
|
1816
1816
|
"task.moveTo": ({"id": "id", "bar": "id"}, True),
|
|
1817
1817
|
"task.link": ({"id": "id", "after": "id"}, False),
|
|
1818
1818
|
"task.unlink": ({"id": "id", "after": "id"}, False),
|
|
1819
1819
|
"task.del": ({"id": "id"}, False),
|
|
1820
|
-
"outcome.add": ({"id": "id?", "name": "str", "source": "str?", "target": "str?", "release": "id?", "date": "iso?", "jira": "keys?"}, False),
|
|
1820
|
+
"outcome.add": ({"id": "id?", "name": "str", "source": "str?", "target": "str?", "release": "id?", "date": "iso?", "jira": "keys?", "capture": "str?", "askedBy": "who?", "raised": "iso?", "lane": "id?"}, False),
|
|
1821
1821
|
"outcome.set": ({"id": "id", "field": "str", "value": "any"}, True),
|
|
1822
1822
|
"outcome.setTarget": ({"id": "id", "target": "str?", "date": "iso?"}, True),
|
|
1823
1823
|
"outcome.setRelease": ({"id": "id", "release": "idOrBlank"}, True),
|
|
1824
1824
|
"outcome.linkJira": ({"id": "id", "keys": "keys"}, False),
|
|
1825
1825
|
"outcome.unlinkJira": ({"id": "id", "key": "str"}, False),
|
|
1826
1826
|
"outcome.drop": ({"id": "id", "reason": "str?"}, False),
|
|
1827
|
+
"outcome.handoff": ({"id": "id", "initiative": "id?", "jira": "str?"}, True),
|
|
1828
|
+
"outcome.reopen": ({"id": "id"}, False),
|
|
1829
|
+
"outcome.raiseAgain": ({"id": "id", "ask": "id", "who": "who?", "raised": "iso?", "source": "str?"}, False),
|
|
1830
|
+
"outcome.unraise": ({"id": "id", "ask": "id"}, False),
|
|
1827
1831
|
"outcome.del": ({"id": "id"}, True),
|
|
1828
1832
|
"link.add": ({"id": "id", "after": "id"}, False),
|
|
1829
1833
|
"link.remove": ({"id": "id", "after": "id"}, False),
|
|
@@ -1836,6 +1840,7 @@ ACTIONS = {
|
|
|
1836
1840
|
"workstream.setReserve": ({"id": "id", "reserve": "any"}, True),
|
|
1837
1841
|
"workstream.set": ({"id": "id", "field": "str", "value": "str"}, True),
|
|
1838
1842
|
"workstream.move": ({"id": "id", "index": "int"}, True),
|
|
1843
|
+
"workstream.reid": ({"lane": "id", "id": "id"}, True),
|
|
1839
1844
|
"workstream.del": ({"id": "id"}, False),
|
|
1840
1845
|
"guide.set": ({"section": "str", "value": "str"}, True),
|
|
1841
1846
|
"guide.setWorkstream": ({"lane": "id", "value": "str"}, True),
|
|
@@ -1845,6 +1850,7 @@ ACTIONS = {
|
|
|
1845
1850
|
"skill.remove": ({"id": "id"}, False),
|
|
1846
1851
|
"meeting.set": ({"id": "id", "field": "str", "value": "str"}, True),
|
|
1847
1852
|
"person.add": ({"id": "id?", "name": "str", "role": "str?", "days": "int?", "outside": "any?"}, False),
|
|
1853
|
+
"group.add": ({"id": "id?", "name": "str", "members": "ids?"}, False),
|
|
1848
1854
|
"person.set": ({"id": "id", "field": "str", "value": "any"}, True),
|
|
1849
1855
|
"person.setAlloc": ({"id": "id", "alloc": "any"}, True),
|
|
1850
1856
|
"person.setSplit": ({"id": "id", "lanes": "weights"}, True),
|
|
@@ -1872,6 +1878,9 @@ ACTIONS = {
|
|
|
1872
1878
|
"jira.notOurs": ({"keys": "keys"}, False),
|
|
1873
1879
|
"jira.epicLink": ({"epic": "str", "id": "id"}, False),
|
|
1874
1880
|
"jira.epicNew": ({"epic": "str", "lane": "id", "id": "id?"}, False),
|
|
1881
|
+
"jira.hide": ({"keys": "keys", "why": "str?"}, False),
|
|
1882
|
+
"jira.unhide": ({"keys": "keys"}, False),
|
|
1883
|
+
"cards.set": ({"field": "str", "value": "any"}, True),
|
|
1875
1884
|
"risk.add": ({"id": "id?", "text": "str", "sev": "any?", "state": "any?", "owners": "any?", "item": "any?", "next": "any?", "waitingOn": "any?", "escalateBy": "any?", "aiHint": "any?"}, False),
|
|
1876
1885
|
"risk.set": ({"id": "id", "field": "str", "value": "any"}, True),
|
|
1877
1886
|
"risk.del": ({"id": "id"}, False),
|
|
@@ -1892,6 +1901,8 @@ def _act_type_ok(t: str, v) -> bool:
|
|
|
1892
1901
|
return isinstance(v, str) and bool(ACT_ID.match(v))
|
|
1893
1902
|
if t == "isoOrNull":
|
|
1894
1903
|
return v is None or _act_type_ok("iso", v)
|
|
1904
|
+
if t == "isoOrAfter":
|
|
1905
|
+
return v == "after" or _act_type_ok("iso", v)
|
|
1895
1906
|
if t == "idOrBlank":
|
|
1896
1907
|
return v == "" or _act_type_ok("id", v)
|
|
1897
1908
|
if t == "ids":
|
|
@@ -1902,17 +1913,41 @@ def _act_type_ok(t: str, v) -> bool:
|
|
|
1902
1913
|
return isinstance(v, (int, float)) and not isinstance(v, bool) and v == v and abs(v) != float("inf")
|
|
1903
1914
|
if t == "int":
|
|
1904
1915
|
return isinstance(v, int) and not isinstance(v, bool)
|
|
1916
|
+
if t == "bool":
|
|
1917
|
+
return isinstance(v, bool)
|
|
1905
1918
|
if t == "iso":
|
|
1906
|
-
return _valid_iso(v)
|
|
1919
|
+
return _valid_iso(v) or _rel_day(v)
|
|
1907
1920
|
if t == "keys":
|
|
1908
1921
|
return isinstance(v, list) and len(v) <= 500 and all(isinstance(k, str) and len(k) <= 40 for k in v)
|
|
1909
1922
|
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)
|
|
1923
|
+
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) or _act_type_ok("who", v)
|
|
1924
|
+
if t == "who": # who asked for a captured item: {"type": "person" or "group", "id": ...}
|
|
1925
|
+
return v is None or (isinstance(v, dict) and set(v) == {"type", "id"} and v["type"] in ("person", "group") and isinstance(v["id"], str) and bool(re.match(r"^[A-Za-z0-9._-]{1,60}$", v["id"])))
|
|
1911
1926
|
if t == "weights":
|
|
1912
1927
|
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
1928
|
return False
|
|
1914
1929
|
|
|
1915
1930
|
|
|
1931
|
+
_REL_DAY = re.compile(r"^(today|tomorrow|yesterday|(\+ ?|in )\d{1,3} ?(d|days?|w|wks?|weeks?)|- ?\d{1,3} ?(d|days?|w|wks?|weeks?)|\d{1,3} (days?|weeks?) ago"
|
|
1932
|
+
r"|((this|next|last) )?(mon|tue|wed|thu|fri|sat|sun)[a-z]{0,6}|(next|last) (week|month)|(this|next) sprint)$")
|
|
1933
|
+
_REL_WEEKDAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
|
|
1934
|
+
|
|
1935
|
+
|
|
1936
|
+
def _rel_day(v) -> bool:
|
|
1937
|
+
"""A day said in words (next Monday, +3d, 2 weeks ago...): the page reads it against today when it checks the change."""
|
|
1938
|
+
if not isinstance(v, str) or len(v) > 40:
|
|
1939
|
+
return False
|
|
1940
|
+
t = " ".join(v.strip().lower().split())
|
|
1941
|
+
if not _REL_DAY.match(t):
|
|
1942
|
+
return False
|
|
1943
|
+
m = re.match(r"^((this|next|last) )?([a-z]+)$", t) # a weekday: the start of its name, at least three letters
|
|
1944
|
+
if m and m.group(3) == "month":
|
|
1945
|
+
return m.group(2) in ("next", "last")
|
|
1946
|
+
if m and m.group(3) not in ("today", "tomorrow", "yesterday", "week", "sprint"):
|
|
1947
|
+
return any(d.startswith(m.group(3)) for d in _REL_WEEKDAYS)
|
|
1948
|
+
return True
|
|
1949
|
+
|
|
1950
|
+
|
|
1916
1951
|
def _valid_iso(v) -> bool:
|
|
1917
1952
|
if not isinstance(v, str) or not re.match(r"^\d{4}-\d{2}-\d{2}$", v):
|
|
1918
1953
|
return False
|
|
@@ -1950,15 +1985,23 @@ def check_action_op(o, at: str, ids: dict, made: dict) -> str:
|
|
|
1950
1985
|
made.setdefault("bar", set()).add(args["id"])
|
|
1951
1986
|
if creates and "id" in spec and not (isinstance(args.get("id"), str) and args["id"]):
|
|
1952
1987
|
return f"{at}: give the new item an id (like risk-vendor-limits), so reading the file again can't add it twice"
|
|
1988
|
+
if name == "task.add" and "start" not in args and "after" not in args:
|
|
1989
|
+
return f"{at}: task.add needs a start, or after: the tasks it starts after."
|
|
1990
|
+
if name == "task.add" and isinstance(args.get("after"), list) and args.get("id") and args["id"] in args["after"]:
|
|
1991
|
+
return f"{at}: a task can't start after itself."
|
|
1992
|
+
if name == "task.add" and args.get("after") == []:
|
|
1993
|
+
return f"{at}: after lists the tasks it starts after; leave it out to give only a start."
|
|
1953
1994
|
for k, v in args.items():
|
|
1954
1995
|
kind = ACT_REF_BY_NOUN.get(noun) if k == "id" else ACT_REF_BY_ARG.get(k)
|
|
1955
1996
|
if name == "task.link" or name == "task.unlink":
|
|
1956
1997
|
kind = "task" if k in ("id", "after") else kind
|
|
1998
|
+
if name == "task.add" and k == "after":
|
|
1999
|
+
kind = "task"
|
|
1957
2000
|
if name in ("task.add", "task.moveTo") and k == "bar":
|
|
1958
2001
|
kind = "bar"
|
|
1959
2002
|
if name == "initiative.waitFor" and k == "gate":
|
|
1960
2003
|
kind = "gate|block"
|
|
1961
|
-
if not kind or (k == "id" and (creates or name
|
|
2004
|
+
if not kind or (k == "id" and (creates or name in ("jira.epicNew", "workstream.reid"))) 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
2005
|
continue
|
|
1963
2006
|
vals = v if isinstance(v, list) else [v]
|
|
1964
2007
|
for x in vals:
|
|
@@ -1966,6 +2009,16 @@ def check_action_op(o, at: str, ids: dict, made: dict) -> str:
|
|
|
1966
2009
|
continue
|
|
1967
2010
|
if not any(x in ids.get(kd, set()) or x in made.get(kd, set()) for kd in kind.split("|")):
|
|
1968
2011
|
return f"{at}: {x[:60]} isn't in the plan (for {k})"
|
|
2012
|
+
if name == "cards.set":
|
|
2013
|
+
e = _cards_set_problem(args)
|
|
2014
|
+
if e:
|
|
2015
|
+
return f"{at}: {e}"
|
|
2016
|
+
if name == "jira.hide" and isinstance(args.get("why"), str) and len(args["why"].strip()) > HIDE_REASON_MAX:
|
|
2017
|
+
return f"{at}: a reason is up to {HIDE_REASON_MAX} characters."
|
|
2018
|
+
if name == "workstream.reid":
|
|
2019
|
+
e = _reid_problem(ids, args)
|
|
2020
|
+
if e:
|
|
2021
|
+
return f"{at}: {e}"
|
|
1969
2022
|
if noun == "jira" and isinstance(args.get("keys"), list) and len({str(k).strip().upper() for k in args["keys"]}) > 100:
|
|
1970
2023
|
return f"{at}: one change places up to 100 issues"
|
|
1971
2024
|
if creates and isinstance(args.get("id"), str):
|
|
@@ -1973,12 +2026,70 @@ def check_action_op(o, at: str, ids: dict, made: dict) -> str:
|
|
|
1973
2026
|
return ""
|
|
1974
2027
|
|
|
1975
2028
|
|
|
2029
|
+
# The Cards page's layout, as the page has it (harness144 checks these against the page): the fields a card can show,
|
|
2030
|
+
# in the order Settings lists them, and the densities. cards.set changes one part of the team's layout.
|
|
2031
|
+
CARD_LAYOUT_FIELDS = ["key", "summary", "status", "assignee", "points", "sprint", "epic", "parent", "labels", "priority", "due", "blocked", "initiative"]
|
|
2032
|
+
CARD_DENSITY = ["compact", "normal", "detailed"]
|
|
2033
|
+
HIDE_REASON_MAX = 200
|
|
2034
|
+
|
|
2035
|
+
|
|
2036
|
+
def _cards_set_problem(args) -> str:
|
|
2037
|
+
f, v = args.get("field"), args.get("value")
|
|
2038
|
+
if f not in ("fields", "density", "width"):
|
|
2039
|
+
return "field is one of fields, density, width."
|
|
2040
|
+
if f == "fields":
|
|
2041
|
+
if not isinstance(v, list) or not v:
|
|
2042
|
+
return "fields is a list of at least one field."
|
|
2043
|
+
bad = next((x for x in v if x not in CARD_LAYOUT_FIELDS), None)
|
|
2044
|
+
if bad is not None:
|
|
2045
|
+
return f"{str(bad)[:30]} isn't a card field. They are {', '.join(CARD_LAYOUT_FIELDS)}."
|
|
2046
|
+
if f == "density" and v not in CARD_DENSITY:
|
|
2047
|
+
return f"density is one of {', '.join(CARD_DENSITY)}."
|
|
2048
|
+
if f == "width" and v not in ("fixed", "points"):
|
|
2049
|
+
return 'width is "fixed" or "points".'
|
|
2050
|
+
return ""
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
# A workstream id the page accepts: one in use, or a former id the plan keeps on its workstream (lane.was), which the page
|
|
2054
|
+
# reads as the id now. A new id (workstream.reid) follows the rules for a new workstream's and is never one of these.
|
|
2055
|
+
LANE_ID = re.compile(r"^(?!(?:constructor|prototype|toString|valueOf|hasOwnProperty)$)[A-Za-z0-9][A-Za-z0-9_.-]{0,59}$")
|
|
2056
|
+
|
|
2057
|
+
|
|
2058
|
+
def _lane_ids(plan) -> set:
|
|
2059
|
+
lanes = [l for l in plan.get("lanes") or [] if isinstance(l, dict)]
|
|
2060
|
+
return {l.get("id") for l in lanes} | {x for l in lanes for x in (l.get("was") if isinstance(l.get("was"), list) else []) if isinstance(x, str)}
|
|
2061
|
+
|
|
2062
|
+
|
|
2063
|
+
def _lane_of(plan) -> dict:
|
|
2064
|
+
"""Each id and former id -> the workstream's id now."""
|
|
2065
|
+
out = {}
|
|
2066
|
+
for l in plan.get("lanes") or []:
|
|
2067
|
+
if isinstance(l, dict) and isinstance(l.get("id"), str):
|
|
2068
|
+
out[l["id"]] = l["id"]
|
|
2069
|
+
for x in l.get("was") if isinstance(l.get("was"), list) else []:
|
|
2070
|
+
if isinstance(x, str):
|
|
2071
|
+
out.setdefault(x, l["id"])
|
|
2072
|
+
return out
|
|
2073
|
+
|
|
2074
|
+
|
|
2075
|
+
def _reid_problem(ids: dict, args: dict) -> str:
|
|
2076
|
+
new, lane_of = args.get("id"), ids.get("laneOf") or {}
|
|
2077
|
+
if not isinstance(new, str) or not LANE_ID.match(new):
|
|
2078
|
+
return "a workstream id is 1 to 60 letters, digits, dots, dashes or underscores, and starts with a letter or digit"
|
|
2079
|
+
if new in lane_of:
|
|
2080
|
+
return "" if lane_of[new] == lane_of.get(args.get("lane")) else f"{new} is, or was, another workstream's id, so it isn't used again"
|
|
2081
|
+
if any(new in ids.get(k, set()) for k in ("bar", "task", "dv", "person", "ms", "gate", "risk", "dec")):
|
|
2082
|
+
return f"{new} is already used"
|
|
2083
|
+
return ""
|
|
2084
|
+
|
|
2085
|
+
|
|
1976
2086
|
def _plan_ids(plan) -> dict:
|
|
1977
2087
|
bars = [b for b in plan.get("bars") or [] if isinstance(b, dict)]
|
|
1978
2088
|
pick = lambda key: {x.get("id") for x in plan.get(key) or [] if isinstance(x, dict)}
|
|
1979
2089
|
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":
|
|
1981
|
-
"block": pick("blocks"), "risk": pick("risks"), "dec": pick("decisions"), "sprint": pick("sprints"), "skill": pick("skills"), "meeting": pick("meetings")
|
|
2090
|
+
"dv": pick("deliverables"), "lane": _lane_ids(plan), "person": pick("people"), "ms": pick("milestones"), "gate": pick("gates"),
|
|
2091
|
+
"block": pick("blocks"), "risk": pick("risks"), "dec": pick("decisions"), "sprint": pick("sprints"), "skill": pick("skills"), "meeting": pick("meetings"),
|
|
2092
|
+
"laneOf": _lane_of(plan)}
|
|
1982
2093
|
|
|
1983
2094
|
|
|
1984
2095
|
def _plan_start(plan) -> _dt.date:
|
|
@@ -2009,7 +2120,7 @@ def _item_err(fields, field, v, plan) -> str:
|
|
|
2009
2120
|
ids = {p.get("id") for p in plan.get("people") or [] if isinstance(p, dict)}
|
|
2010
2121
|
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
2122
|
if t == "lane":
|
|
2012
|
-
return "" if
|
|
2123
|
+
return "" if v in _lane_ids(plan) else f'workstream "{str(v)[:40]}" isn\'t in the plan'
|
|
2013
2124
|
weeks = (plan.get("meta") or {}).get("weeks") or 0
|
|
2014
2125
|
if t == "start":
|
|
2015
2126
|
if not _iso_ok(v):
|
|
@@ -2063,7 +2174,7 @@ def _value_err(field, v, plan, status) -> str:
|
|
|
2063
2174
|
if t == "release":
|
|
2064
2175
|
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
2176
|
if t == "lane":
|
|
2066
|
-
return "" if
|
|
2177
|
+
return "" if v in _lane_ids(plan) else f'workstream "{str(v)[:40]}" isn\'t in the plan'
|
|
2067
2178
|
if t == "people":
|
|
2068
2179
|
ids = {p.get("id") for p in plan.get("people") or [] if isinstance(p, dict)}
|
|
2069
2180
|
return "" if isinstance(v, list) and all(x in ids for x in v) else "owners must be ids of people in the plan"
|