alissa-tools-github-genloop 0.1.0__py3-none-any.whl
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.
- alissa/tools/github/genloop/__init__.py +14 -0
- alissa/tools/github/genloop/__main__.py +89 -0
- alissa/tools/github/genloop/alissa_cli.py +89 -0
- alissa/tools/github/genloop/alissa_client.py +183 -0
- alissa/tools/github/genloop/config.py +294 -0
- alissa/tools/github/genloop/github.py +286 -0
- alissa/tools/github/genloop/loop.py +971 -0
- alissa/tools/github/genloop/loop_events.py +115 -0
- alissa/tools/github/genloop/pass_directive.py +325 -0
- alissa/tools/github/genloop/plan.py +461 -0
- alissa/tools/github/genloop/proc.py +61 -0
- alissa/tools/github/genloop/state.py +113 -0
- alissa/tools/github/genloop/version +1 -0
- alissa/tools/github/genloop/version.py +46 -0
- alissa_tools_github_genloop-0.1.0.dist-info/METADATA +45 -0
- alissa_tools_github_genloop-0.1.0.dist-info/RECORD +21 -0
- alissa_tools_github_genloop-0.1.0.dist-info/WHEEL +5 -0
- alissa_tools_github_genloop-0.1.0.dist-info/entry_points.txt +2 -0
- alissa_tools_github_genloop-0.1.0.dist-info/licenses/LICENSE +202 -0
- alissa_tools_github_genloop-0.1.0.dist-info/licenses/NOTICE +5 -0
- alissa_tools_github_genloop-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""The plan directory as the SEAT sees it: frontmatter, never prose (D14).
|
|
2
|
+
|
|
3
|
+
After a pass the seat reads the pushed branch's `plan.md` and `tasks/*.md`
|
|
4
|
+
from the plans repository, parses their FRONTMATTER -- the structured fields
|
|
5
|
+
the plan row and the trailers need (`id`, `title`, `anchor`, `program`,
|
|
6
|
+
`sources`, each task's `id` and `commitClass`) -- and renders the PR body by
|
|
7
|
+
COPYING `plan.md`'s header lines, *Why now* and *Summary*. It never judges
|
|
8
|
+
the prose; the judgment was the session's, and the refutation is revloop's.
|
|
9
|
+
|
|
10
|
+
The YAML subset parser below is copied from orcloop's `plans.py` (the plan
|
|
11
|
+
close edge reads the same files with it): the dist carries no third-party
|
|
12
|
+
runtime dependency, and the files are written to a schema that needs only
|
|
13
|
+
`key: value` pairs, flow mappings and sequences, and block sequences of such
|
|
14
|
+
values. `plan-lint` has already validated the files, so a shape this parser
|
|
15
|
+
cannot read is a bypass, not a style.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any, Sequence
|
|
23
|
+
|
|
24
|
+
# The marker the seat writes into every plan PR body, on a line of its own --
|
|
25
|
+
# one of revloop's four plan signals (§6.1) and orcloop's plan-PR test.
|
|
26
|
+
PLAN_MARKER = "<!-- alissa-genloop:plan v1 -->"
|
|
27
|
+
PLAN_TRAILER = "Alissa-Plan"
|
|
28
|
+
ANCHOR_TRAILER = "Plan-Anchor"
|
|
29
|
+
|
|
30
|
+
# Design §7.1, in severity order; a task's `commitClass` draws on the last ten.
|
|
31
|
+
COMMIT_CLASSES = (
|
|
32
|
+
"unanchored",
|
|
33
|
+
"schema-migration",
|
|
34
|
+
"auth-secrets",
|
|
35
|
+
"pricing-billing",
|
|
36
|
+
"customer-promise",
|
|
37
|
+
"product-surface",
|
|
38
|
+
"gate-verification",
|
|
39
|
+
"drift-fix",
|
|
40
|
+
"flaky-test",
|
|
41
|
+
"exhaust-followup",
|
|
42
|
+
"doc-correction",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
PLAN_ID_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9][a-z0-9-]{2,47}$")
|
|
46
|
+
PLAN_BRANCH_RE = re.compile(r"^PLAN-\d{8}T\d{6}Z-[a-z0-9-]+$")
|
|
47
|
+
SOURCE_KEY_RE = re.compile(r"^(gate|later|minor|esc|drift|stale):\S+$")
|
|
48
|
+
_FRONTMATTER_RE = re.compile(r"^---\n([\s\S]*?)\n---\n?([\s\S]*)$")
|
|
49
|
+
_TASK_FILE_RE = re.compile(r"^[a-z0-9][a-z0-9-]*\.md$")
|
|
50
|
+
MAX_TASKS = 8
|
|
51
|
+
PR_BODY_MAX_CHARS = 60000
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PlanError(ValueError):
|
|
55
|
+
"""The pushed plan cannot be read into a plan row; the seat holds it."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- the YAML subset (copied from orcloop's plans.py) ------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class FrontmatterError(ValueError):
|
|
62
|
+
"""The frontmatter is not the YAML subset the plan files are written in."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --- the YAML subset ----------------------------------------------------------
|
|
66
|
+
#
|
|
67
|
+
# The dist carries no third-party runtime dependency, and the plan files are
|
|
68
|
+
# written to a schema (design §5.3) that needs only: `key: value` pairs; a
|
|
69
|
+
# value that is a plain or quoted scalar, a flow mapping `{ a: b }` or a flow
|
|
70
|
+
# sequence `[a, b]`; and a block sequence of such values under a key. Anything
|
|
71
|
+
# else -- a block mapping inside a list, an anchor, a multi-line scalar -- is
|
|
72
|
+
# refused (rule 2): the lint has already validated the files against the
|
|
73
|
+
# schema, so a shape this parser cannot read is a bypass, not a style.
|
|
74
|
+
|
|
75
|
+
_PLAIN_INT_RE = re.compile(r"^[-+]?[0-9]+$")
|
|
76
|
+
_PLAIN_FLOAT_RE = re.compile(r"^[-+]?(?:[0-9]+\.[0-9]*|\.[0-9]+)(?:[eE][-+]?[0-9]+)?$")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _strip_comment(line: str) -> str:
|
|
80
|
+
"""A line with its trailing ` # comment` removed, quotes respected."""
|
|
81
|
+
quote = ""
|
|
82
|
+
for index, char in enumerate(line):
|
|
83
|
+
if quote:
|
|
84
|
+
if char == quote:
|
|
85
|
+
quote = ""
|
|
86
|
+
elif char == "\\" and quote == '"':
|
|
87
|
+
continue
|
|
88
|
+
elif char in "\"'":
|
|
89
|
+
quote = char
|
|
90
|
+
elif char == "#" and (index == 0 or line[index - 1] in " \t"):
|
|
91
|
+
return line[:index].rstrip()
|
|
92
|
+
return line.rstrip()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _Flow:
|
|
96
|
+
"""A tiny recursive-descent reader for one value (flow or scalar)."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, text: str):
|
|
99
|
+
self.text = text
|
|
100
|
+
self.pos = 0
|
|
101
|
+
|
|
102
|
+
def _ws(self) -> None:
|
|
103
|
+
while self.pos < len(self.text) and self.text[self.pos] in " \t":
|
|
104
|
+
self.pos += 1
|
|
105
|
+
|
|
106
|
+
def value(self, stops: str = "") -> Any:
|
|
107
|
+
self._ws()
|
|
108
|
+
if self.pos >= len(self.text):
|
|
109
|
+
return None
|
|
110
|
+
char = self.text[self.pos]
|
|
111
|
+
if char == "{":
|
|
112
|
+
return self._mapping()
|
|
113
|
+
if char == "[":
|
|
114
|
+
return self._sequence()
|
|
115
|
+
if char == '"':
|
|
116
|
+
return self._double()
|
|
117
|
+
if char == "'":
|
|
118
|
+
return self._single()
|
|
119
|
+
return self._plain(stops)
|
|
120
|
+
|
|
121
|
+
def _mapping(self) -> dict:
|
|
122
|
+
self.pos += 1
|
|
123
|
+
out: dict = {}
|
|
124
|
+
while True:
|
|
125
|
+
self._ws()
|
|
126
|
+
if self.pos < len(self.text) and self.text[self.pos] == "}":
|
|
127
|
+
self.pos += 1
|
|
128
|
+
return out
|
|
129
|
+
key = self.value(stops=":,}")
|
|
130
|
+
self._ws()
|
|
131
|
+
if self.pos >= len(self.text) or self.text[self.pos] != ":":
|
|
132
|
+
raise FrontmatterError(f"expected `:` in a flow mapping: {self.text!r}")
|
|
133
|
+
self.pos += 1
|
|
134
|
+
if not isinstance(key, str) or key in out:
|
|
135
|
+
raise FrontmatterError(f"bad or repeated key {key!r} in {self.text!r}")
|
|
136
|
+
out[key] = self.value(stops=",}")
|
|
137
|
+
self._ws()
|
|
138
|
+
if self.pos < len(self.text) and self.text[self.pos] == ",":
|
|
139
|
+
self.pos += 1
|
|
140
|
+
continue
|
|
141
|
+
if self.pos < len(self.text) and self.text[self.pos] == "}":
|
|
142
|
+
self.pos += 1
|
|
143
|
+
return out
|
|
144
|
+
raise FrontmatterError(f"unterminated flow mapping: {self.text!r}")
|
|
145
|
+
|
|
146
|
+
def _sequence(self) -> list:
|
|
147
|
+
self.pos += 1
|
|
148
|
+
out: list = []
|
|
149
|
+
while True:
|
|
150
|
+
self._ws()
|
|
151
|
+
if self.pos < len(self.text) and self.text[self.pos] == "]":
|
|
152
|
+
self.pos += 1
|
|
153
|
+
return out
|
|
154
|
+
out.append(self.value(stops=",]"))
|
|
155
|
+
self._ws()
|
|
156
|
+
if self.pos < len(self.text) and self.text[self.pos] == ",":
|
|
157
|
+
self.pos += 1
|
|
158
|
+
continue
|
|
159
|
+
if self.pos < len(self.text) and self.text[self.pos] == "]":
|
|
160
|
+
self.pos += 1
|
|
161
|
+
return out
|
|
162
|
+
raise FrontmatterError(f"unterminated flow sequence: {self.text!r}")
|
|
163
|
+
|
|
164
|
+
def _double(self) -> str:
|
|
165
|
+
self.pos += 1
|
|
166
|
+
out: list[str] = []
|
|
167
|
+
escapes = {'"': '"', "\\": "\\", "n": "\n", "t": "\t", "/": "/"}
|
|
168
|
+
while self.pos < len(self.text):
|
|
169
|
+
char = self.text[self.pos]
|
|
170
|
+
if char == "\\":
|
|
171
|
+
nxt = self.text[self.pos + 1:self.pos + 2]
|
|
172
|
+
if nxt not in escapes:
|
|
173
|
+
raise FrontmatterError(f"unsupported escape \\{nxt} in {self.text!r}")
|
|
174
|
+
out.append(escapes[nxt])
|
|
175
|
+
self.pos += 2
|
|
176
|
+
continue
|
|
177
|
+
if char == '"':
|
|
178
|
+
self.pos += 1
|
|
179
|
+
return "".join(out)
|
|
180
|
+
out.append(char)
|
|
181
|
+
self.pos += 1
|
|
182
|
+
raise FrontmatterError(f"unterminated string: {self.text!r}")
|
|
183
|
+
|
|
184
|
+
def _single(self) -> str:
|
|
185
|
+
self.pos += 1
|
|
186
|
+
out: list[str] = []
|
|
187
|
+
while self.pos < len(self.text):
|
|
188
|
+
char = self.text[self.pos]
|
|
189
|
+
if char == "'":
|
|
190
|
+
if self.text[self.pos + 1:self.pos + 2] == "'":
|
|
191
|
+
out.append("'")
|
|
192
|
+
self.pos += 2
|
|
193
|
+
continue
|
|
194
|
+
self.pos += 1
|
|
195
|
+
return "".join(out)
|
|
196
|
+
out.append(char)
|
|
197
|
+
self.pos += 1
|
|
198
|
+
raise FrontmatterError(f"unterminated string: {self.text!r}")
|
|
199
|
+
|
|
200
|
+
def _plain(self, stops: str) -> Any:
|
|
201
|
+
start = self.pos
|
|
202
|
+
while self.pos < len(self.text):
|
|
203
|
+
char = self.text[self.pos]
|
|
204
|
+
if char in stops and (char != ":" or self.text[self.pos + 1:self.pos + 2] in ("", " ", "\t")):
|
|
205
|
+
break
|
|
206
|
+
self.pos += 1
|
|
207
|
+
token = self.text[start:self.pos].strip()
|
|
208
|
+
if token.startswith(("&", "*", "!", "|", ">", "%", "@", "`")):
|
|
209
|
+
raise FrontmatterError(f"unsupported YAML construct {token!r}")
|
|
210
|
+
if token in ("", "~", "null", "Null", "NULL"):
|
|
211
|
+
return None
|
|
212
|
+
if token in ("true", "True", "TRUE"):
|
|
213
|
+
return True
|
|
214
|
+
if token in ("false", "False", "FALSE"):
|
|
215
|
+
return False
|
|
216
|
+
if _PLAIN_INT_RE.match(token):
|
|
217
|
+
return int(token)
|
|
218
|
+
if _PLAIN_FLOAT_RE.match(token):
|
|
219
|
+
return float(token)
|
|
220
|
+
return token
|
|
221
|
+
|
|
222
|
+
def done(self) -> bool:
|
|
223
|
+
self._ws()
|
|
224
|
+
return self.pos >= len(self.text)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _one_value(text: str) -> Any:
|
|
228
|
+
reader = _Flow(text)
|
|
229
|
+
value = reader.value()
|
|
230
|
+
if not reader.done():
|
|
231
|
+
raise FrontmatterError(f"trailing text after a value: {text!r}")
|
|
232
|
+
return value
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def parse_frontmatter_yaml(text: str) -> dict:
|
|
236
|
+
"""The frontmatter subset (see above) as a dict, key order kept. Raises
|
|
237
|
+
`FrontmatterError` on anything outside it."""
|
|
238
|
+
out: dict = {}
|
|
239
|
+
current_list: list | None = None
|
|
240
|
+
for raw in text.split("\n"):
|
|
241
|
+
line = _strip_comment(raw)
|
|
242
|
+
if not line.strip():
|
|
243
|
+
continue
|
|
244
|
+
if line[0] in " \t" or line.startswith("-"):
|
|
245
|
+
stripped = line.strip()
|
|
246
|
+
if current_list is None or not stripped.startswith("- ") and stripped != "-":
|
|
247
|
+
raise FrontmatterError(f"unexpected indented line {raw!r}")
|
|
248
|
+
item = stripped[1:].strip()
|
|
249
|
+
if re.match(r"^[^\s\"'{\[][^:]*:(\s|$)", item):
|
|
250
|
+
raise FrontmatterError(f"a block mapping inside a list is not supported: {raw!r}")
|
|
251
|
+
current_list.append(_one_value(item))
|
|
252
|
+
continue
|
|
253
|
+
key, sep, rest = line.partition(":")
|
|
254
|
+
key = key.strip()
|
|
255
|
+
if not sep or not key or key in out or not re.match(r"^[A-Za-z_][A-Za-z0-9_-]*$", key):
|
|
256
|
+
raise FrontmatterError(f"not a `key: value` line: {raw!r}")
|
|
257
|
+
if rest.strip():
|
|
258
|
+
out[key] = _one_value(rest.strip())
|
|
259
|
+
current_list = None
|
|
260
|
+
else:
|
|
261
|
+
current_list = []
|
|
262
|
+
out[key] = current_list
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# --- the plan, as the seat reads it ------------------------------------------
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass(frozen=True)
|
|
270
|
+
class PlanTask:
|
|
271
|
+
id: str
|
|
272
|
+
commit_class: str
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass(frozen=True)
|
|
276
|
+
class Plan:
|
|
277
|
+
plan_id: str
|
|
278
|
+
title: str
|
|
279
|
+
anchor: dict
|
|
280
|
+
program: str
|
|
281
|
+
sources: tuple[dict, ...]
|
|
282
|
+
tasks: tuple[PlanTask, ...]
|
|
283
|
+
body: str = field(repr=False)
|
|
284
|
+
frontmatter: dict = field(repr=False, default_factory=dict)
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def plan_class(self) -> str:
|
|
288
|
+
"""The most severe `commitClass` of the tasks (§7.1's order)."""
|
|
289
|
+
ranks = [COMMIT_CLASSES.index(t.commit_class) for t in self.tasks if t.commit_class in COMMIT_CLASSES]
|
|
290
|
+
return COMMIT_CLASSES[min(ranks)] if ranks else "unanchored"
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def source_keys(self) -> list[str]:
|
|
294
|
+
return [str(s["key"]) for s in self.sources]
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def anchor_ref(self) -> str:
|
|
298
|
+
"""`<kind>:<ref>` for the `Plan-Anchor:` trailer and the rule pattern."""
|
|
299
|
+
return anchor_ref(self.anchor)
|
|
300
|
+
|
|
301
|
+
def loop_plan_record(self, *, repo: str, pr_number: int, status: str, at_ms: int | None = None) -> dict:
|
|
302
|
+
"""The `POST /v1/loop/plans` body (Studio G3-2)."""
|
|
303
|
+
record: dict = {
|
|
304
|
+
"repo": repo,
|
|
305
|
+
"prNumber": int(pr_number),
|
|
306
|
+
"planId": self.plan_id,
|
|
307
|
+
"title": self.title[:300],
|
|
308
|
+
"anchor": dict(self.anchor),
|
|
309
|
+
"class": self.plan_class,
|
|
310
|
+
"status": status,
|
|
311
|
+
"sources": [{"key": str(s["key"]), "evidence": str(s.get("evidence") or "")[:2000]} for s in self.sources],
|
|
312
|
+
"tasks": [{"id": t.id} for t in self.tasks],
|
|
313
|
+
}
|
|
314
|
+
if at_ms is not None:
|
|
315
|
+
record["at"] = int(at_ms)
|
|
316
|
+
return record
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def anchor_ref(anchor: dict) -> str:
|
|
320
|
+
kind = str(anchor.get("kind") or "")
|
|
321
|
+
if kind == "milestone":
|
|
322
|
+
return f"milestone:{anchor.get('milestoneId')}"
|
|
323
|
+
if kind == "criterion":
|
|
324
|
+
return f"criterion:{anchor.get('milestoneId')}/{anchor.get('criterionId')}"
|
|
325
|
+
if kind == "objective":
|
|
326
|
+
return f"objective:{anchor.get('objectiveId')}"
|
|
327
|
+
if kind == "exhaust":
|
|
328
|
+
return f"exhaust:{anchor.get('class')}"
|
|
329
|
+
return f"{kind or 'unknown'}:"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def anchor_rule_pattern(anchor: dict) -> str | None:
|
|
333
|
+
"""The `proposalRules` pattern a rejection writes for the anchor (§4.4):
|
|
334
|
+
`milestone:<id>` or `objective:<id>`; an exhaust anchor's keys are its
|
|
335
|
+
sources, which get their own rows."""
|
|
336
|
+
kind = anchor.get("kind")
|
|
337
|
+
if kind in ("milestone", "criterion") and anchor.get("milestoneId"):
|
|
338
|
+
return f"milestone:{anchor['milestoneId']}"
|
|
339
|
+
if kind == "objective" and anchor.get("objectiveId"):
|
|
340
|
+
return f"objective:{anchor['objectiveId']}"
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def split_frontmatter(path: str, text: str) -> tuple[dict, str]:
|
|
345
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
346
|
+
match = _FRONTMATTER_RE.match(text)
|
|
347
|
+
if match is None:
|
|
348
|
+
raise PlanError(f"{path} has no YAML frontmatter")
|
|
349
|
+
try:
|
|
350
|
+
return parse_frontmatter_yaml(match.group(1)), match.group(2)
|
|
351
|
+
except FrontmatterError as exc:
|
|
352
|
+
raise PlanError(f"{path}: {exc}") from None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def task_file_names(names: Sequence[str]) -> list[str]:
|
|
356
|
+
return sorted(name for name in names if _TASK_FILE_RE.match(name))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def parse_plan(plan_md: str, task_files: dict[str, str], *, expect_id: str | None = None) -> Plan:
|
|
360
|
+
"""The plan row's facts from `plan.md` and `{file name: text}` of the
|
|
361
|
+
task files. Raises `PlanError` naming the first thing it cannot read."""
|
|
362
|
+
front, body = split_frontmatter("plan.md", plan_md)
|
|
363
|
+
plan_id = front.get("id")
|
|
364
|
+
if not isinstance(plan_id, str) or not PLAN_ID_RE.match(plan_id):
|
|
365
|
+
raise PlanError(f"plan.md id {plan_id!r} is not <yyyy-mm-dd>-<slug>")
|
|
366
|
+
if expect_id is not None and plan_id != expect_id:
|
|
367
|
+
raise PlanError(f"plan.md id {plan_id!r} is not the recorded planId {expect_id!r}")
|
|
368
|
+
title = front.get("title")
|
|
369
|
+
if not isinstance(title, str) or not title.strip():
|
|
370
|
+
raise PlanError("plan.md has no title")
|
|
371
|
+
anchor = front.get("anchor")
|
|
372
|
+
if not isinstance(anchor, dict) or anchor.get("kind") not in ("milestone", "criterion", "objective", "exhaust"):
|
|
373
|
+
raise PlanError(f"plan.md anchor {anchor!r} is not one of milestone, criterion, objective, exhaust")
|
|
374
|
+
program = front.get("program")
|
|
375
|
+
if not isinstance(program, str) or not program:
|
|
376
|
+
raise PlanError("plan.md has no program")
|
|
377
|
+
sources: list[dict] = []
|
|
378
|
+
for entry in front.get("sources") or []:
|
|
379
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("key"), str) \
|
|
380
|
+
or not SOURCE_KEY_RE.match(entry["key"]):
|
|
381
|
+
raise PlanError(f"plan.md source {entry!r} is not {{key, evidence}} with a source key")
|
|
382
|
+
sources.append(entry)
|
|
383
|
+
names = task_file_names(list(task_files))
|
|
384
|
+
if not 1 <= len(names) <= MAX_TASKS:
|
|
385
|
+
raise PlanError(f"the plan has {len(names)} task files (1–{MAX_TASKS})")
|
|
386
|
+
tasks: list[PlanTask] = []
|
|
387
|
+
for name in names:
|
|
388
|
+
task_front, _ = split_frontmatter(f"tasks/{name}", task_files[name])
|
|
389
|
+
task_id = task_front.get("id")
|
|
390
|
+
if not isinstance(task_id, str) or f"{task_id}.md" != name:
|
|
391
|
+
raise PlanError(f"tasks/{name}: id {task_id!r} does not match the file name")
|
|
392
|
+
klass = task_front.get("commitClass")
|
|
393
|
+
if klass not in COMMIT_CLASSES[1:]:
|
|
394
|
+
raise PlanError(f"tasks/{name}: commitClass {klass!r} is not one of §7.1's task classes")
|
|
395
|
+
tasks.append(PlanTask(id=task_id, commit_class=str(klass)))
|
|
396
|
+
return Plan(
|
|
397
|
+
plan_id=plan_id, title=title.strip(), anchor=anchor, program=program, sources=tuple(sources),
|
|
398
|
+
tasks=tuple(tasks), body=body, frontmatter=front,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# --- the PR body ---------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _section(body: str, heading: str) -> str:
|
|
406
|
+
"""The text under `## <heading>` up to the next `## ` heading, or ""."""
|
|
407
|
+
lines = body.split("\n")
|
|
408
|
+
out: list[str] = []
|
|
409
|
+
inside = False
|
|
410
|
+
for line in lines:
|
|
411
|
+
if line.startswith("## "):
|
|
412
|
+
if inside:
|
|
413
|
+
break
|
|
414
|
+
inside = line[3:].strip() == heading
|
|
415
|
+
continue
|
|
416
|
+
if inside:
|
|
417
|
+
out.append(line)
|
|
418
|
+
return "\n".join(out).strip()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def render_pr_title(plan: Plan) -> str:
|
|
422
|
+
return f"Plan: {plan.title}"[:256]
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def render_pr_body(plan: Plan, *, branch: str, plans_dir: str, budget_note: str = "") -> str:
|
|
426
|
+
"""The plan PR's body: the header lines, *Why now* and *Summary* COPIED
|
|
427
|
+
from `plan.md`, the marker on a line of its own, and the two trailers as
|
|
428
|
+
the body's last lines. The files are the truth; this is a rendering."""
|
|
429
|
+
budget = plan.frontmatter.get("budget") if isinstance(plan.frontmatter.get("budget"), dict) else {}
|
|
430
|
+
header = [
|
|
431
|
+
f"# Plan: {plan.title}",
|
|
432
|
+
PLAN_MARKER,
|
|
433
|
+
"",
|
|
434
|
+
f"- **Directory:** `{plans_dir}/` on `{branch}`",
|
|
435
|
+
f"- **Anchor:** `{plan.anchor_ref}` · **Program:** `{plan.program}`"
|
|
436
|
+
+ (f" · **Milestone:** `{plan.frontmatter.get('milestone')}`" if plan.frontmatter.get("milestone") else ""),
|
|
437
|
+
f"- **Class:** `{plan.plan_class}` · **Tasks:** {len(plan.tasks)}",
|
|
438
|
+
]
|
|
439
|
+
if budget:
|
|
440
|
+
header.append(f"- **Budget estimate:** ${budget.get('estimateUsd')} — {budget.get('basis')}")
|
|
441
|
+
if plan.source_keys:
|
|
442
|
+
header.append("- **Sources:** " + ", ".join(f"`{k}`" for k in plan.source_keys))
|
|
443
|
+
if budget_note:
|
|
444
|
+
header.append(f"- **Budget:** {budget_note}")
|
|
445
|
+
parts = ["\n".join(header)]
|
|
446
|
+
why_now = _section(plan.body, "Why now")
|
|
447
|
+
summary = _section(plan.body, "Summary")
|
|
448
|
+
if why_now:
|
|
449
|
+
parts.append("## Why now\n\n" + why_now)
|
|
450
|
+
if summary:
|
|
451
|
+
parts.append("## Summary\n\n" + summary)
|
|
452
|
+
parts.append(
|
|
453
|
+
"_Opened by the genloop seat. The plan directory is the truth; this body is a rendering. "
|
|
454
|
+
"Review it under the plan directive; amendments come from the next genloop pass._"
|
|
455
|
+
)
|
|
456
|
+
trailers = f"{PLAN_TRAILER}: {plan.plan_id}\n{ANCHOR_TRAILER}: {plan.anchor_ref}"
|
|
457
|
+
body = "\n\n".join(parts)
|
|
458
|
+
keep = PR_BODY_MAX_CHARS - len(trailers) - 40
|
|
459
|
+
if len(body) > keep:
|
|
460
|
+
body = body[:keep].rstrip() + "\n\n[… truncated — read plan.md]"
|
|
461
|
+
return body + "\n\n" + trailers + "\n"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Subprocess helpers, copied from orcloop's `proc.py`.
|
|
2
|
+
|
|
3
|
+
`github` shells out to `gh` (which keeps its own auth handling) and
|
|
4
|
+
`alissa_cli` to `alissa`. The Alissa REST client does NOT go through here: it
|
|
5
|
+
is in-process `urllib`, never a subprocess. Nothing in this module ever
|
|
6
|
+
touches a token.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
from typing import Sequence
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CommandError(RuntimeError):
|
|
21
|
+
def __init__(self, argv: Sequence[str], returncode: int, stderr: str):
|
|
22
|
+
self.argv = list(argv)
|
|
23
|
+
self.returncode = returncode
|
|
24
|
+
self.stderr = stderr
|
|
25
|
+
super().__init__(f"{argv[0]} exited {returncode}: {stderr.strip()[:400]}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run(
|
|
29
|
+
argv: Sequence[str],
|
|
30
|
+
*,
|
|
31
|
+
timeout: int = 60,
|
|
32
|
+
check: bool = True,
|
|
33
|
+
cwd: "str | os.PathLike[str] | None" = None,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Run a command, return stdout. Never uses shell=True."""
|
|
36
|
+
log.debug("exec: %s", " ".join(argv))
|
|
37
|
+
try:
|
|
38
|
+
proc = subprocess.run(
|
|
39
|
+
list(argv),
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
timeout=timeout,
|
|
43
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
44
|
+
)
|
|
45
|
+
except subprocess.TimeoutExpired as exc:
|
|
46
|
+
raise CommandError(argv, -1, f"timed out after {timeout}s") from exc
|
|
47
|
+
|
|
48
|
+
if check and proc.returncode != 0:
|
|
49
|
+
raise CommandError(argv, proc.returncode, proc.stderr)
|
|
50
|
+
return proc.stdout
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_json(argv: Sequence[str], *, timeout: int = 60):
|
|
54
|
+
"""Run a command whose stdout is JSON."""
|
|
55
|
+
out = run(argv, timeout=timeout).strip()
|
|
56
|
+
if not out:
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
return json.loads(out)
|
|
60
|
+
except json.JSONDecodeError as exc:
|
|
61
|
+
raise CommandError(argv, 0, f"expected JSON, got: {out[:300]}") from exc
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The seat's own state: a small JSON file under the state dir.
|
|
2
|
+
|
|
3
|
+
L8 (external truth): the spent-set, the plans and the rules live in Studio and
|
|
4
|
+
GitHub and are re-derived every pass. What is left for this file is what only
|
|
5
|
+
the seat knows -- the pass clock, the stamps it spawned, which records it has
|
|
6
|
+
ingested, which PR a stamp opened, and the once-only marks on pages and
|
|
7
|
+
writes. Every row carries the time it was written and ages out after
|
|
8
|
+
`LEDGER_MAX_AGE_SECONDS`, like every other seat's ledger.
|
|
9
|
+
|
|
10
|
+
Writes are atomic (temp file + rename), so a crash mid-write leaves the
|
|
11
|
+
previous state whole.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import tempfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
STATE_FILENAME = "genloop-state.json"
|
|
25
|
+
LEDGER_MAX_AGE_SECONDS = 30 * 86400
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_json_atomic(path: Path, payload: object) -> None:
|
|
29
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
|
|
31
|
+
try:
|
|
32
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
33
|
+
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
34
|
+
handle.write("\n")
|
|
35
|
+
os.replace(tmp, path)
|
|
36
|
+
except BaseException:
|
|
37
|
+
try:
|
|
38
|
+
os.unlink(tmp)
|
|
39
|
+
except OSError:
|
|
40
|
+
pass
|
|
41
|
+
raise
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class State:
|
|
45
|
+
"""`meta` (latest-wins strings) and `ledger` (key -> written-at epoch)."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, path: Path):
|
|
48
|
+
self.path = path
|
|
49
|
+
self.meta: dict[str, str] = {}
|
|
50
|
+
self.ledger: dict[str, int] = {}
|
|
51
|
+
self._load()
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def in_dir(cls, state_dir: Path) -> "State":
|
|
55
|
+
return cls(state_dir / STATE_FILENAME)
|
|
56
|
+
|
|
57
|
+
def _load(self) -> None:
|
|
58
|
+
try:
|
|
59
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
60
|
+
except FileNotFoundError:
|
|
61
|
+
return
|
|
62
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
63
|
+
# A corrupt file must not wedge the seat; it re-derives what it can.
|
|
64
|
+
log.error("state: %s is unreadable (%s) — starting from an empty ledger", self.path, exc)
|
|
65
|
+
return
|
|
66
|
+
if isinstance(raw, dict):
|
|
67
|
+
meta = raw.get("meta")
|
|
68
|
+
ledger = raw.get("ledger")
|
|
69
|
+
if isinstance(meta, dict):
|
|
70
|
+
self.meta = {str(k): str(v) for k, v in meta.items()}
|
|
71
|
+
if isinstance(ledger, dict):
|
|
72
|
+
self.ledger = {str(k): int(v) for k, v in ledger.items() if isinstance(v, (int, float))}
|
|
73
|
+
|
|
74
|
+
def save(self) -> None:
|
|
75
|
+
write_json_atomic(self.path, {"meta": self.meta, "ledger": self.ledger})
|
|
76
|
+
|
|
77
|
+
# -- meta ----------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def get_meta(self, key: str) -> str | None:
|
|
80
|
+
return self.meta.get(key)
|
|
81
|
+
|
|
82
|
+
def set_meta(self, key: str, value: str) -> None:
|
|
83
|
+
self.meta[key] = value
|
|
84
|
+
self.save()
|
|
85
|
+
|
|
86
|
+
def del_meta(self, key: str) -> None:
|
|
87
|
+
if self.meta.pop(key, None) is not None:
|
|
88
|
+
self.save()
|
|
89
|
+
|
|
90
|
+
# -- ledger --------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def has(self, key: str) -> bool:
|
|
93
|
+
return key in self.ledger
|
|
94
|
+
|
|
95
|
+
def mark(self, key: str, *, at: int) -> None:
|
|
96
|
+
self.ledger[key] = int(at)
|
|
97
|
+
self.save()
|
|
98
|
+
|
|
99
|
+
def unmark(self, key: str) -> None:
|
|
100
|
+
if self.ledger.pop(key, None) is not None:
|
|
101
|
+
self.save()
|
|
102
|
+
|
|
103
|
+
def keys_with_prefix(self, prefix: str) -> list[str]:
|
|
104
|
+
return sorted(key for key in self.ledger if key.startswith(prefix))
|
|
105
|
+
|
|
106
|
+
def age_out(self, *, now: int) -> int:
|
|
107
|
+
"""Drop ledger rows older than the max age; returns how many."""
|
|
108
|
+
stale = [key for key, at in self.ledger.items() if now - at > LEDGER_MAX_AGE_SECONDS]
|
|
109
|
+
for key in stale:
|
|
110
|
+
del self.ledger[key]
|
|
111
|
+
if stale:
|
|
112
|
+
self.save()
|
|
113
|
+
return len(stale)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True, slots=True)
|
|
6
|
+
class Version:
|
|
7
|
+
name: str
|
|
8
|
+
value: str
|
|
9
|
+
|
|
10
|
+
def components(self, as_int: bool = False) -> list:
|
|
11
|
+
return [int(val) if as_int else val for val in self.value.split(".")]
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def major(self) -> int:
|
|
15
|
+
component, *_ = self.components(as_int=True)
|
|
16
|
+
return component
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def minor(self) -> int:
|
|
20
|
+
_, component, *_ = self.components(as_int=True)
|
|
21
|
+
return component
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def patch(self) -> int:
|
|
25
|
+
*_, component = self.components(as_int=True)
|
|
26
|
+
return component
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_path(cls, dirpath: str, name: str):
|
|
30
|
+
for file in os.listdir(dirpath):
|
|
31
|
+
if file.lower().endswith("version"):
|
|
32
|
+
filepath = os.path.join(dirpath, file)
|
|
33
|
+
break
|
|
34
|
+
else:
|
|
35
|
+
raise ValueError("Version file not found for package name: " + name)
|
|
36
|
+
|
|
37
|
+
with open(filepath, "r") as version_file:
|
|
38
|
+
version_value = version_file.readline().strip()
|
|
39
|
+
return cls(name=name, value=version_value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
version = Version.from_path(name="alissa-tools-github-genloop", dirpath=os.path.dirname(__file__))
|
|
44
|
+
except Exception:
|
|
45
|
+
print("Version file not found for package name: alissa-tools-github-genloop, using 0.0.0")
|
|
46
|
+
version = Version(name="alissa-tools-github-genloop", value="0.0.0")
|