napkinstack 0.12.0__tar.gz → 0.13.0__tar.gz
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.
- {napkinstack-0.12.0 → napkinstack-0.13.0}/PKG-INFO +4 -4
- {napkinstack-0.12.0 → napkinstack-0.13.0}/README.md +3 -3
- {napkinstack-0.12.0 → napkinstack-0.13.0}/pyproject.toml +1 -1
- {napkinstack-0.12.0 → napkinstack-0.13.0}/pyproject.toml.orig +1 -1
- napkinstack-0.13.0/src/napkinstack/board.py +359 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/cli.py +4 -1
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/fitness/plan.py +41 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/hold.py +22 -5
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/pull_request.py +2 -2
- {napkinstack-0.12.0 → napkinstack-0.13.0}/LICENSE +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/assurance.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/attest.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/compat.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/discovery.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/doctor.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/fitness/boundaries.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/fitness/hygiene.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/fitness/manifests.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/fitness/pr_scope.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/landed.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/modules.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/project.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/provenance.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/settings.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/skills.py +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/contracts/README.md +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/module/README.md +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
- {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/module/tests/.gitkeep +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: napkinstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: Engineering framework for several teams and their agents working in one repository: modules, contracts, guardrails in CI.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -20,7 +20,7 @@ modules, contracts, guardrails in CI. On the Django or Rails model, one command
|
|
|
20
20
|
the project, which then receives new versions on demand; no application stack is imposed.
|
|
21
21
|
Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
|
|
22
22
|
|
|
23
|
-
> **Status: v0.
|
|
23
|
+
> **Status: v0.13.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
|
|
24
24
|
> What each version changes, engine and project apart: [`CHANGELOG.md`](CHANGELOG.md).
|
|
25
25
|
> What is open: [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
26
26
|
|
|
@@ -84,8 +84,8 @@ To try it without installing anything, or to install it from this repository ins
|
|
|
84
84
|
registry — always pinned to a release tag:
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
|
-
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.
|
|
88
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
87
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.13.0" nstack init my-project
|
|
88
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.13.0"
|
|
89
89
|
```
|
|
90
90
|
|
|
91
91
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -5,7 +5,7 @@ modules, contracts, guardrails in CI. On the Django or Rails model, one command
|
|
|
5
5
|
the project, which then receives new versions on demand; no application stack is imposed.
|
|
6
6
|
Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
|
|
7
7
|
|
|
8
|
-
> **Status: v0.
|
|
8
|
+
> **Status: v0.13.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
|
|
9
9
|
> What each version changes, engine and project apart: [`CHANGELOG.md`](CHANGELOG.md).
|
|
10
10
|
> What is open: [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
11
11
|
|
|
@@ -69,8 +69,8 @@ To try it without installing anything, or to install it from this repository ins
|
|
|
69
69
|
registry — always pinned to a release tag:
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
|
-
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.
|
|
73
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
72
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.13.0" nstack init my-project
|
|
73
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.13.0"
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The plan shown on the forge, one way (PDR-0016, ADR-0008).
|
|
3
|
+
|
|
4
|
+
The cycle files are the plan; this command shows them on GitHub — a milestone per accepted,
|
|
5
|
+
closed or stopped cycle, an issue per deliverable — and keeps the display equal to them. Its own
|
|
6
|
+
objects carry a marker in their body; an object without one is never touched. What the forge
|
|
7
|
+
holds decides nothing but whether the display must be rewritten.
|
|
8
|
+
|
|
9
|
+
Usage : nstack board [--root ROOT] [--dry-run]
|
|
10
|
+
Output: 0 whatever the forge answers — what was made, changed, restored or not shown is said.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
import urllib.parse
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from napkinstack.fitness import plan
|
|
24
|
+
from napkinstack.pull_request import DELIVERABLE
|
|
25
|
+
|
|
26
|
+
SHOWN = {"accepted", "closed", "stopped"} # a proposed cycle decides nothing yet
|
|
27
|
+
OPEN_STATES = {"proposed", "ready", "in-progress"}
|
|
28
|
+
LABEL = "deliverable"
|
|
29
|
+
STATE_LABEL = "state: {}"
|
|
30
|
+
EDITED_THERE = "Generated from {path}: the plan is edited there, in a pull request — an edit made here is undone."
|
|
31
|
+
PACE = 1.0 # seconds between two writes: GitHub's advice for content-creating requests
|
|
32
|
+
NUMBER = re.compile(r"^(\d+)")
|
|
33
|
+
HEADING = re.compile(r"^#\s+(.+?)\s*$", re.M)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def marker(kind: str, key: str) -> str:
|
|
37
|
+
return f"<!-- nstack:{kind} {key} -->"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
MARKER = re.compile(r"<!-- nstack:(cycle|deliverable) (\S+) -->")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Shown:
|
|
45
|
+
"""What the files call for, for one forge object."""
|
|
46
|
+
|
|
47
|
+
kind: str # cycle | deliverable
|
|
48
|
+
key: str # the cycle's number, or the deliverable's identifier
|
|
49
|
+
title: str
|
|
50
|
+
body: str
|
|
51
|
+
state: str # open | closed
|
|
52
|
+
reason: str | None = None # an issue's state_reason when closed
|
|
53
|
+
due: str | None = None # a milestone's due date, ISO
|
|
54
|
+
cycle: str | None = None # a deliverable's cycle number
|
|
55
|
+
labels: list[str] = field(default_factory=list)
|
|
56
|
+
assignee: str | None = None
|
|
57
|
+
start: datetime.date | None = None # a cycle's start: how far back pull requests are read
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _handle(person: str) -> str | None:
|
|
61
|
+
"""A GitHub login from a hold's person, when it names one: a handle, or a noreply address."""
|
|
62
|
+
found = re.fullmatch(r"\s*@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\s*", person)
|
|
63
|
+
if found:
|
|
64
|
+
return found[1]
|
|
65
|
+
noreply = re.search(r"<(?:\d+\+)?([A-Za-z0-9-]+)@users\.noreply\.github\.com>", person)
|
|
66
|
+
return noreply[1] if noreply else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _reason(state: str) -> str:
|
|
70
|
+
return "completed" if state == "accepted" else "not_planned"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def desired(root: Path, holders: dict[str, str] | None = None, pulls: dict[str, list[int]] | None = None,
|
|
74
|
+
today: datetime.date | None = None) -> tuple[list[Shown], list[str]]:
|
|
75
|
+
"""The display the cycle files call for, and what cannot be shown and why. `holders`:
|
|
76
|
+
deliverable identifier to the hold's person; `pulls`: identifier to the pull requests whose
|
|
77
|
+
description names it."""
|
|
78
|
+
holders, pulls = holders or {}, pulls or {}
|
|
79
|
+
cycles: dict[str, list[tuple[Path, dict]]] = {}
|
|
80
|
+
for path in plan.cycle_files(root):
|
|
81
|
+
data = plan.read_front_matter(path)[0]
|
|
82
|
+
found = NUMBER.match(path.stem)
|
|
83
|
+
if data and data.get("status") in SHOWN and found:
|
|
84
|
+
cycles.setdefault(found[1], []).append((path, data))
|
|
85
|
+
said = [f"cycle {number} not shown: {len(files)} cycle files carry its number "
|
|
86
|
+
f"({', '.join(path.name for path, _ in files)})" for number, files in cycles.items() if len(files) > 1]
|
|
87
|
+
seen: dict[str, list[str]] = {}
|
|
88
|
+
for number, files in cycles.items():
|
|
89
|
+
items = files[0][1].get("deliverables") if len(files) == 1 else None
|
|
90
|
+
for item in items if isinstance(items, list) else []:
|
|
91
|
+
if isinstance(item, dict) and item.get("id"):
|
|
92
|
+
seen.setdefault(str(item["id"]), []).append(number)
|
|
93
|
+
twice = {ident for ident, numbers in seen.items() if len(numbers) > 1}
|
|
94
|
+
said += [f"{ident} not shown: its identifier is used in cycles {', '.join(seen[ident])}" for ident in sorted(twice)]
|
|
95
|
+
shown: list[Shown] = []
|
|
96
|
+
for number, files in sorted(cycles.items()):
|
|
97
|
+
if len(files) > 1:
|
|
98
|
+
continue
|
|
99
|
+
path, data = files[0]
|
|
100
|
+
relative = path.relative_to(root).as_posix()
|
|
101
|
+
closed = data.get("status") != "accepted"
|
|
102
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
103
|
+
front = plan.FRONT_MATTER.match(text)
|
|
104
|
+
heading = HEADING.search(text[front.end():] if front else text)
|
|
105
|
+
title = heading[1] if heading and heading[1].lower().startswith("cycle") else f"Cycle {path.stem}"
|
|
106
|
+
goal = str(data.get("goal") or "").strip()
|
|
107
|
+
end = plan.as_date(data.get("end"))
|
|
108
|
+
shown.append(Shown(
|
|
109
|
+
kind="cycle", key=number, title=title, state="closed" if closed else "open",
|
|
110
|
+
body="\n\n".join(part for part in (goal, plan.progress_of(path, data, today, appetite=False),
|
|
111
|
+
EDITED_THERE.format(path=relative), marker("cycle", number)) if part),
|
|
112
|
+
due=str(end) if end else None, start=plan.as_date(data.get("start"))))
|
|
113
|
+
items = data.get("deliverables")
|
|
114
|
+
for item in items if isinstance(items, list) else []:
|
|
115
|
+
if not isinstance(item, dict) or not item.get("id") or str(item["id"]) in twice:
|
|
116
|
+
continue
|
|
117
|
+
ident, state = str(item["id"]), str(item.get("state") or "")
|
|
118
|
+
listed = item.get("acceptance")
|
|
119
|
+
criteria = [f"- [{'x' if state == 'accepted' else ' '}] {line}"
|
|
120
|
+
for line in (listed if isinstance(listed, list) else [listed] if isinstance(listed, str) else [])
|
|
121
|
+
if isinstance(line, str)]
|
|
122
|
+
person = holders.get(ident)
|
|
123
|
+
lines = [f"**Module**: `{item.get('module')}`" if item.get("module") else "",
|
|
124
|
+
f"**State**: {state}" + (f" · **held by** {person}" if person else ""),
|
|
125
|
+
"**Acceptance criteria**\n\n" + "\n".join(criteria) if criteria else "",
|
|
126
|
+
"**Pull requests**: " + ", ".join(f"#{n}" for n in pulls.get(ident, [])[:10]) if pulls.get(ident) else "",
|
|
127
|
+
EDITED_THERE.format(path=relative), marker("deliverable", ident)]
|
|
128
|
+
shut = closed or state not in OPEN_STATES # a closed cycle's unfinished work closes with it
|
|
129
|
+
shown.append(Shown(
|
|
130
|
+
kind="deliverable", key=ident, title=f"{ident} — {item.get('title') or ident}",
|
|
131
|
+
body="\n\n".join(line for line in lines if line), cycle=number,
|
|
132
|
+
state="closed" if shut else "open", reason=_reason(state) if shut else None,
|
|
133
|
+
labels=[LABEL, STATE_LABEL.format(state)], assignee=_handle(person) if person else None))
|
|
134
|
+
return shown, said
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --- the forge -------------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def _pages(forge, path: str, stop=lambda item: False) -> list[dict]:
|
|
140
|
+
"""Every object a listing returns, page after page — or until `stop` says the rest is older."""
|
|
141
|
+
from napkinstack.hold import _call
|
|
142
|
+
|
|
143
|
+
found, page = [], 1
|
|
144
|
+
while True:
|
|
145
|
+
answer = _call(forge, "GET", f"{path}{'&' if '?' in path else '?'}per_page=100&page={page}")
|
|
146
|
+
if not isinstance(answer, list) or not answer:
|
|
147
|
+
return found
|
|
148
|
+
for item in answer:
|
|
149
|
+
if isinstance(item, dict):
|
|
150
|
+
if stop(item):
|
|
151
|
+
return found
|
|
152
|
+
found.append(item)
|
|
153
|
+
if len(answer) < 100:
|
|
154
|
+
return found
|
|
155
|
+
page += 1
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _own(objects: list[dict], field_name: str, kind: str) -> dict[str, dict]:
|
|
159
|
+
"""The display's own objects, by the key their marker carries; others are never touched."""
|
|
160
|
+
own = {}
|
|
161
|
+
for item in objects:
|
|
162
|
+
found = MARKER.search(str(item.get(field_name) or ""))
|
|
163
|
+
if found and found[1] == kind:
|
|
164
|
+
own.setdefault(found[2], item)
|
|
165
|
+
return own
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _holders(root: Path, idents: list[tuple[str, str]]) -> dict[str, str] | None:
|
|
169
|
+
"""The person holding each open deliverable, or None when the holds cannot be read."""
|
|
170
|
+
from napkinstack import hold
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
hold.fetch(root)
|
|
174
|
+
except hold.Unreachable:
|
|
175
|
+
return None
|
|
176
|
+
stems = {NUMBER.match(path.stem)[1]: path.stem for path in plan.cycle_files(root) if NUMBER.match(path.stem)}
|
|
177
|
+
holders = {}
|
|
178
|
+
for number, ident in idents:
|
|
179
|
+
found = hold.read(root, stems.get(number, number), ident)
|
|
180
|
+
if found.held:
|
|
181
|
+
holders[ident] = found.person
|
|
182
|
+
return holders
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _pulls(forge, since: datetime.date | None) -> dict[str, list[int]]:
|
|
186
|
+
"""The pull requests whose description names a deliverable, newest first, back to the
|
|
187
|
+
earliest start of a shown cycle — read with the rule K3 reads."""
|
|
188
|
+
def older(item: dict) -> bool:
|
|
189
|
+
created = str(item.get("created_at") or "")[:10]
|
|
190
|
+
return bool(since and created and created < since.isoformat())
|
|
191
|
+
|
|
192
|
+
pulls: dict[str, list[int]] = {}
|
|
193
|
+
for item in _pages(forge, "/pulls?state=all&sort=created&direction=desc", older):
|
|
194
|
+
found = DELIVERABLE.search(str(item.get("body") or ""))
|
|
195
|
+
if found and item.get("number"):
|
|
196
|
+
pulls.setdefault(found[1].upper(), []).append(int(item["number"]))
|
|
197
|
+
return pulls
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _clean(text) -> str:
|
|
201
|
+
return "\n".join(line.rstrip() for line in str(text or "").replace("\r\n", "\n").strip().split("\n"))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def run(root: Path, dry_run: bool = False, today: datetime.date | None = None) -> int:
|
|
205
|
+
from napkinstack import hold
|
|
206
|
+
|
|
207
|
+
first, said = desired(root, today=today)
|
|
208
|
+
if not first:
|
|
209
|
+
_tell(said, "Plan on the forge: nothing to show — no accepted, closed or stopped cycle")
|
|
210
|
+
return 0
|
|
211
|
+
forge = hold._forge(root, hold.remote(root))
|
|
212
|
+
if forge is None:
|
|
213
|
+
_tell(["not shown: no GitHub remote, or no token"], "Plan on the forge")
|
|
214
|
+
return 0
|
|
215
|
+
lines = list(said)
|
|
216
|
+
try:
|
|
217
|
+
open_ones = [(item.cycle, item.key) for item in first if item.kind == "deliverable" and item.state == "open"]
|
|
218
|
+
holders = _holders(root, open_ones)
|
|
219
|
+
if holders is None:
|
|
220
|
+
lines.append("holders not shown: the holds cannot be read — assignees left as they are")
|
|
221
|
+
starts = [item.start for item in first if item.kind == "cycle" and item.start]
|
|
222
|
+
shown, _ = desired(root, holders or {}, _pulls(forge, min(starts) if starts else None), today)
|
|
223
|
+
milestones = _pages(forge, "/milestones?state=all")
|
|
224
|
+
# Every issue, not those carrying the label: a label removed by hand leaves the marker.
|
|
225
|
+
issues = [item for item in _pages(forge, "/issues?state=all") if "pull_request" not in item]
|
|
226
|
+
lines += _apply(forge, shown, milestones, issues, dry_run, keep_assignees=holders is None)
|
|
227
|
+
except hold.Refused as error:
|
|
228
|
+
limited = re.search(r"HTTP 429|rate limit", str(error), re.I)
|
|
229
|
+
lines.append(f"not shown further: {error}" + (" — partially shown, the rest at the next run" if limited else ""))
|
|
230
|
+
except Exception as error: # an answer nobody foresaw: said, never a failed merge
|
|
231
|
+
lines.append(f"not shown further: {type(error).__name__}: {error}")
|
|
232
|
+
_tell(lines, f"Plan on the forge ({forge[1]})" + (" — dry run, nothing applied" if dry_run else ""))
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _tell(lines: list[str], head: str) -> None:
|
|
237
|
+
"""Said in the output; in GitHub Actions, also in the job's summary, and what was not shown as
|
|
238
|
+
a warning a person sees on the run."""
|
|
239
|
+
print(f"{head}:")
|
|
240
|
+
print("\n".join(f" {line}" for line in lines) or " in step with the cycle files: nothing to change")
|
|
241
|
+
summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
242
|
+
if summary:
|
|
243
|
+
with open(summary, "a", encoding="utf-8") as out:
|
|
244
|
+
out.write(f"### {head}\n\n" + ("\n".join(f"- {line}" for line in lines) or "In step: nothing to change.") + "\n")
|
|
245
|
+
if os.environ.get("GITHUB_ACTIONS") == "true":
|
|
246
|
+
for line in lines:
|
|
247
|
+
if "not shown" in line:
|
|
248
|
+
print(f"::warning title=Plan on the forge::{line}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _apply(forge, shown: list[Shown], milestones: list[dict], issues: list[dict], dry_run: bool,
|
|
252
|
+
keep_assignees: bool = False) -> list[str]:
|
|
253
|
+
from napkinstack.hold import Refused, _call
|
|
254
|
+
|
|
255
|
+
said: list[str] = []
|
|
256
|
+
|
|
257
|
+
def call(*args):
|
|
258
|
+
if dry_run:
|
|
259
|
+
return {}
|
|
260
|
+
answer = _call(forge, *args)
|
|
261
|
+
time.sleep(PACE)
|
|
262
|
+
return answer
|
|
263
|
+
|
|
264
|
+
own_milestones, own_issues = _own(milestones, "description", "cycle"), _own(issues, "body", "deliverable")
|
|
265
|
+
foreign_titles = {item.get("title") for item in milestones if not MARKER.search(str(item.get("description") or ""))}
|
|
266
|
+
present = {str(label.get("name") or "").lower() for label in _pages(forge, "/labels")} # one name, any case
|
|
267
|
+
for name in sorted(label for label in {label for item in shown for label in item.labels} if label.lower() not in present):
|
|
268
|
+
call("POST", "/labels", {"name": name, "color": "1d76db" if name == LABEL else "c5def5",
|
|
269
|
+
"description": "Shown from the cycle files by nstack board"})
|
|
270
|
+
said.append(f"made label {name}")
|
|
271
|
+
|
|
272
|
+
numbers: dict[str, int | None] = {}
|
|
273
|
+
for item in (entry for entry in shown if entry.kind == "cycle"):
|
|
274
|
+
# Noon, UTC: GitHub keeps a due date as midnight, Pacific time, of the day it receives —
|
|
275
|
+
# sent at midnight UTC, it would read the day before.
|
|
276
|
+
want = {"title": item.title, "description": item.body, "state": item.state,
|
|
277
|
+
**({"due_on": f"{item.due}T12:00:00Z"} if item.due else {})}
|
|
278
|
+
have = own_milestones.get(item.key)
|
|
279
|
+
if have is None:
|
|
280
|
+
if item.title in foreign_titles:
|
|
281
|
+
said.append(f"cycle {item.key} not shown: a milestone titled '{item.title}' exists that the display "
|
|
282
|
+
"did not make — rename it, or put the marker back")
|
|
283
|
+
numbers[item.key] = None
|
|
284
|
+
continue
|
|
285
|
+
made = call("POST", "/milestones", want)
|
|
286
|
+
numbers[item.key] = made.get("number") if isinstance(made, dict) else None
|
|
287
|
+
said.append(f"made milestone {item.title}")
|
|
288
|
+
continue
|
|
289
|
+
numbers[item.key] = have.get("number")
|
|
290
|
+
if not have.get("number"):
|
|
291
|
+
continue
|
|
292
|
+
changed = [name for name in ("title", "state") if have.get(name) != want[name]]
|
|
293
|
+
if _clean(have.get("description")) != _clean(want["description"]):
|
|
294
|
+
changed.append("description")
|
|
295
|
+
if item.due and str(have.get("due_on") or "")[:10] != item.due:
|
|
296
|
+
changed.append("due date")
|
|
297
|
+
if changed:
|
|
298
|
+
call("PATCH", f"/milestones/{have['number']}", want)
|
|
299
|
+
said.append(f"restored milestone {item.title}: {', '.join(changed)} — the plan is edited in the cycle file")
|
|
300
|
+
ours = sum(1 for issue in own_issues.values()
|
|
301
|
+
if isinstance(issue.get("milestone"), dict) and issue["milestone"].get("number") == have.get("number"))
|
|
302
|
+
theirs = int(have.get("open_issues") or 0) + int(have.get("closed_issues") or 0) - ours
|
|
303
|
+
if theirs > 0:
|
|
304
|
+
said.append(f"{theirs} item(s) in {item.title} not from the plan: GitHub's bar counts them, the "
|
|
305
|
+
"description's line does not")
|
|
306
|
+
|
|
307
|
+
assignable: dict[str, bool] = {}
|
|
308
|
+
|
|
309
|
+
def can_assign(login: str) -> bool:
|
|
310
|
+
if login not in assignable:
|
|
311
|
+
try:
|
|
312
|
+
_call(forge, "GET", f"/assignees/{urllib.parse.quote(login)}")
|
|
313
|
+
assignable[login] = True
|
|
314
|
+
except Refused:
|
|
315
|
+
assignable[login] = False
|
|
316
|
+
return assignable[login]
|
|
317
|
+
|
|
318
|
+
for item in (entry for entry in shown if entry.kind == "deliverable"):
|
|
319
|
+
have = own_issues.get(item.key)
|
|
320
|
+
if have is not None and not have.get("number"):
|
|
321
|
+
continue
|
|
322
|
+
assignees = [item.assignee] if item.assignee and can_assign(item.assignee) else []
|
|
323
|
+
if keep_assignees and have is not None:
|
|
324
|
+
assignees = sorted(a.get("login") for a in have.get("assignees") or [] if isinstance(a, dict))
|
|
325
|
+
want = {"title": item.title, "body": item.body, "labels": item.labels,
|
|
326
|
+
"milestone": numbers.get(item.cycle), "assignees": assignees}
|
|
327
|
+
if have is None:
|
|
328
|
+
made = call("POST", "/issues", want)
|
|
329
|
+
if item.state == "closed" and isinstance(made, dict) and made.get("number"):
|
|
330
|
+
call("PATCH", f"/issues/{made['number']}", {"state": "closed", "state_reason": item.reason})
|
|
331
|
+
said.append(f"made issue {item.title}")
|
|
332
|
+
continue
|
|
333
|
+
changed = ["title"] if have.get("title") != want["title"] else []
|
|
334
|
+
if _clean(have.get("body")) != _clean(want["body"]):
|
|
335
|
+
changed.append("body")
|
|
336
|
+
if ({str(label.get("name") or "").lower() for label in have.get("labels") or [] if isinstance(label, dict)}
|
|
337
|
+
!= {label.lower() for label in item.labels}):
|
|
338
|
+
changed.append("labels")
|
|
339
|
+
milestone = have.get("milestone")
|
|
340
|
+
if (milestone.get("number") if isinstance(milestone, dict) else None) != want["milestone"]:
|
|
341
|
+
changed.append("milestone")
|
|
342
|
+
if {a.get("login") for a in have.get("assignees") or [] if isinstance(a, dict)} != set(assignees):
|
|
343
|
+
changed.append("assignees")
|
|
344
|
+
if have.get("state") != item.state:
|
|
345
|
+
changed.append("state")
|
|
346
|
+
want.update(state=item.state, state_reason=item.reason or "reopened")
|
|
347
|
+
elif item.state == "closed" and have.get("state_reason") != item.reason:
|
|
348
|
+
changed.append("state reason")
|
|
349
|
+
want.update(state="closed", state_reason=item.reason)
|
|
350
|
+
if changed:
|
|
351
|
+
call("PATCH", f"/issues/{have['number']}", want)
|
|
352
|
+
said.append(f"restored issue {item.title}: {', '.join(changed)}")
|
|
353
|
+
|
|
354
|
+
wanted = {item.key for item in shown if item.kind == "deliverable"}
|
|
355
|
+
for key, have in own_issues.items():
|
|
356
|
+
if key not in wanted and have.get("state") == "open" and have.get("number"):
|
|
357
|
+
call("PATCH", f"/issues/{have['number']}", {"state": "closed", "state_reason": "not_planned"})
|
|
358
|
+
said.append(f"closed issue {have.get('title')}: its deliverable is no longer in a shown cycle")
|
|
359
|
+
return said
|
|
@@ -8,7 +8,7 @@ import os
|
|
|
8
8
|
import sys
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
-
from napkinstack import assurance, attest, hold
|
|
11
|
+
from napkinstack import assurance, attest, board, hold
|
|
12
12
|
from napkinstack import (compat, discovery, doctor, landed, modules, provenance, settings,
|
|
13
13
|
pull_request, skills)
|
|
14
14
|
from napkinstack.fitness import boundaries, hygiene, manifests, plan, pr_scope
|
|
@@ -175,6 +175,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
175
175
|
rs.add_argument("deliverable", help="the deliverable's identifier, D<n>")
|
|
176
176
|
rs.add_argument("--session", help="the agent's session, written on the release")
|
|
177
177
|
rs.add_argument("--person", help="who releases it, such as @alice (default: git's user)")
|
|
178
|
+
bd = _add(sub, "board", "shows the cycles and their deliverables on the forge, one way: milestones and issues",
|
|
179
|
+
lambda a: board.run(a.root, a.dry_run))
|
|
180
|
+
bd.add_argument("--dry-run", action="store_true", help="say what would change, change nothing")
|
|
178
181
|
ds = _add(sub, "discover", "starts a discovery from an idea file, for the team's agent",
|
|
179
182
|
lambda a: discovery.run(a.root, a.idea))
|
|
180
183
|
ds.add_argument("idea", type=Path, help="the idea, a .md or .txt file")
|
|
@@ -149,6 +149,45 @@ def accepted_cycle(root: Path) -> tuple[Path, dict] | None:
|
|
|
149
149
|
return None
|
|
150
150
|
|
|
151
151
|
|
|
152
|
+
PROGRESS = (("accepted", "accepted"), ("in-progress", "in progress"), ("ready", "ready"),
|
|
153
|
+
("proposed", "proposed"), ("deferred", "deferred")) # dropped: out of the count
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def progress(root: Path, today: datetime.date | None = None) -> str | None:
|
|
157
|
+
"""The accepted cycle in one line, read from its front matter and never written down:
|
|
158
|
+
deliverables by state — a dropped one counts in nothing — and where the cycle stands in its
|
|
159
|
+
appetite (#219). None when no cycle is accepted."""
|
|
160
|
+
cycle = accepted_cycle(root)
|
|
161
|
+
return None if cycle is None else progress_of(*cycle, today=today)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def progress_of(path: Path, data: dict, today: datetime.date | None = None, appetite: bool = True) -> str:
|
|
165
|
+
"""One cycle's line, accepted or not: what `progress` says. Without the appetite — the
|
|
166
|
+
day and the days left, which change every day — it is what the forge's display repeats
|
|
167
|
+
(ADR-0008)."""
|
|
168
|
+
items = data.get("deliverables")
|
|
169
|
+
states = [item.get("state") for item in (items if isinstance(items, list) else []) if isinstance(item, dict)]
|
|
170
|
+
counted = [state for state in states if state != "dropped"]
|
|
171
|
+
said = [f"{states.count('accepted')} of {len(counted)} accepted"]
|
|
172
|
+
said += [f"{counted.count(state)} {name}" for state, name in PROGRESS[1:] if counted.count(state)]
|
|
173
|
+
other = len(counted) - sum(counted.count(state) for state, _ in PROGRESS)
|
|
174
|
+
said += [f"{other} other"] if other else []
|
|
175
|
+
today = today or datetime.date.today()
|
|
176
|
+
start, end = as_date(data.get("start")), as_date(data.get("end"))
|
|
177
|
+
|
|
178
|
+
def days(count: int) -> str:
|
|
179
|
+
return f"{count} day" + ("" if count == 1 else "s")
|
|
180
|
+
|
|
181
|
+
if start and end and appetite:
|
|
182
|
+
if today < start:
|
|
183
|
+
said.append(f"starts in {days((start - today).days)} ({start})")
|
|
184
|
+
elif today >= end: # the first day the cycle is over (the cycle form), as K2 reads it
|
|
185
|
+
said.append(f"ended on {end}, {days((today - end).days)} ago — the circuit breaker: the decider chooses")
|
|
186
|
+
else:
|
|
187
|
+
said.append(f"day {(today - start).days + 1} of {(end - start).days}, {(end - today).days} left (ends {end})")
|
|
188
|
+
return f"Cycle {path.stem}: " + " · ".join(said)
|
|
189
|
+
|
|
190
|
+
|
|
152
191
|
def _left(path: Path, form: tuple[str, ...]) -> list[str]:
|
|
153
192
|
"""The words of `form` a seat still holds, as whole lines: a page may hold any tag."""
|
|
154
193
|
lines = {line.strip() for line in path.read_text(encoding="utf-8", errors="replace").splitlines()}
|
|
@@ -323,6 +362,8 @@ def run(root: Path) -> int:
|
|
|
323
362
|
f"({CHARTER}, status: accepted)")
|
|
324
363
|
|
|
325
364
|
print(f"Plan: charter {'present' if charter.is_file() else 'absent'}, {len(cycles)} cycle(s).")
|
|
365
|
+
if len(accepted) == 1 and accepted_charter:
|
|
366
|
+
print(progress(root))
|
|
326
367
|
for failure in failures:
|
|
327
368
|
print(f" FAIL {failure}")
|
|
328
369
|
if failures:
|
|
@@ -14,6 +14,7 @@ Output: 0 when the hold is taken, renewed or released; 1 when refused — sayin
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import base64
|
|
17
18
|
import datetime
|
|
18
19
|
import json
|
|
19
20
|
import os
|
|
@@ -76,7 +77,7 @@ def fetch(root: Path, again: bool = False) -> str | None:
|
|
|
76
77
|
name = remote(root)
|
|
77
78
|
if name is not None:
|
|
78
79
|
try:
|
|
79
|
-
got = _git(root, "fetch", "--quiet", "--prune", "--no-tags", name, f"+HEAD:{MIRROR}/base",
|
|
80
|
+
got = _git(root, *_ci_credential(root, name), "fetch", "--quiet", "--prune", "--no-tags", name, f"+HEAD:{MIRROR}/base",
|
|
80
81
|
f"+refs/heads/{PREFIX}/*:{MIRROR}/hold/*", f"+refs/heads/d*:{MIRROR}/work/d*",
|
|
81
82
|
timeout=60)
|
|
82
83
|
except subprocess.TimeoutExpired:
|
|
@@ -86,6 +87,19 @@ def fetch(root: Path, again: bool = False) -> str | None:
|
|
|
86
87
|
return name
|
|
87
88
|
|
|
88
89
|
|
|
90
|
+
def _ci_credential(root: Path, name: str) -> list[str]:
|
|
91
|
+
"""In GitHub Actions, the job's token for this one command: the checkout keeps no credential
|
|
92
|
+
(`persist-credentials: false`), and a private repository's holds are read with it (ADR-0008).
|
|
93
|
+
Never on a workstation, and never to another host."""
|
|
94
|
+
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
|
95
|
+
url = _git(root, "config", f"remote.{name}.url").stdout.strip()
|
|
96
|
+
if os.environ.get("GITHUB_ACTIONS") != "true" or not token or not url.startswith("https://github.com/"):
|
|
97
|
+
return []
|
|
98
|
+
secret = base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
|
99
|
+
print(f"::add-mask::{secret}", flush=True) # never printed in clear by the runner, as actions/checkout does
|
|
100
|
+
return ["-c", f"http.https://github.com/.extraheader=AUTHORIZATION: basic {secret}"]
|
|
101
|
+
|
|
102
|
+
|
|
89
103
|
def _hold_ref(root: Path, cycle: str, ident: str) -> str:
|
|
90
104
|
return f"{MIRROR}/hold/{cycle}/{ident}" if remote(root) else f"refs/heads/{PREFIX}/{cycle}/{ident}"
|
|
91
105
|
|
|
@@ -161,7 +175,7 @@ def _deliverable(root: Path, ident: str) -> tuple[str, str] | str:
|
|
|
161
175
|
return ("no accepted cycle: a deliverable is taken from the cycle in progress.\n"
|
|
162
176
|
" Action: the decider accepts a cycle (.nstack/playbooks/framing.md).")
|
|
163
177
|
path, data = cycle
|
|
164
|
-
items = {str(item.get("id")): item for item in data.get("deliverables")
|
|
178
|
+
items = {str(item.get("id")): item for item in (data.get("deliverables") if isinstance(data.get("deliverables"), list) else []) if isinstance(item, dict)}
|
|
165
179
|
if ident not in items:
|
|
166
180
|
return (f"{ident} is not in {path.stem}.\n Action: name one of {', '.join(items) or 'its deliverables'}.")
|
|
167
181
|
state = items[ident].get("state")
|
|
@@ -441,8 +455,9 @@ def show(root: Path) -> int:
|
|
|
441
455
|
except Unreachable as error:
|
|
442
456
|
print(f"\nHolds in {path.stem}: holds not read — the remote cannot be read.\n {error}")
|
|
443
457
|
return 0
|
|
444
|
-
lines = []
|
|
445
|
-
|
|
458
|
+
lines, held, stale = [], 0, 0
|
|
459
|
+
items = data.get("deliverables")
|
|
460
|
+
for item in items if isinstance(items, list) else []:
|
|
446
461
|
if not isinstance(item, dict):
|
|
447
462
|
continue
|
|
448
463
|
ident, state = str(item.get("id")), item.get("state")
|
|
@@ -454,12 +469,14 @@ def show(root: Path) -> int:
|
|
|
454
469
|
elif state not in TAKEABLE:
|
|
455
470
|
lines.append(f" {ident} ended — {state}; was {said(found)}")
|
|
456
471
|
elif time.time() >= found.worked + days * DAY:
|
|
472
|
+
held, stale = held + 1, stale + 1
|
|
457
473
|
lines.append(f" {ident} STALE — {said(found)}: its holder renews (nstack claim {ident}); "
|
|
458
474
|
f"anyone else may nstack claim {ident} --take-over")
|
|
459
475
|
else:
|
|
476
|
+
held += 1
|
|
460
477
|
lines.append(f" {ident} {said(found)}")
|
|
461
478
|
where = f"from {name}" if name else "local only: no remote"
|
|
462
|
-
print(f"\nHolds in {path.stem} ({where}; stale after {days} days without work):")
|
|
479
|
+
print(f"\nHolds in {path.stem}: {held} held, of which {stale} stale ({where}; stale after {days} days without work):")
|
|
463
480
|
print("\n".join(lines) or " no ready deliverable")
|
|
464
481
|
return 0
|
|
465
482
|
|
|
@@ -377,14 +377,14 @@ def check_cycle(root: Path, body: str, labels: set[str], today: datetime.date, f
|
|
|
377
377
|
return
|
|
378
378
|
path, data = cycle
|
|
379
379
|
end = plan.as_date(data.get("end"))
|
|
380
|
-
if end is not None and today
|
|
380
|
+
if end is not None and today >= end: # `end` is the first day the cycle is over (the cycle form)
|
|
381
381
|
fail("K2", f"circuit breaker: {path.name} ended on {end}, with no automatic extension.\n"
|
|
382
382
|
" Action: the decider chooses — ship what is accepted (status: closed), "
|
|
383
383
|
"frame a new cycle with a new appetite, or stop the project (status: stopped).")
|
|
384
384
|
return
|
|
385
385
|
match = DELIVERABLE.search(body)
|
|
386
386
|
match = match and [match[0], match[1].upper()]
|
|
387
|
-
states = {str(item.get("id")): item.get("state") for item in data.get("deliverables")
|
|
387
|
+
states = {str(item.get("id")): item.get("state") for item in (data.get("deliverables") if isinstance(data.get("deliverables"), list) else [])
|
|
388
388
|
if isinstance(item, dict)}
|
|
389
389
|
if not match:
|
|
390
390
|
fail("K3", f"no deliverable named.\n Action: \"Deliverable: D<n>\" in the description, "
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/contracts/docs/runbook.md
RENAMED
|
File without changes
|
{napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/contracts/tests/.gitkeep
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|