loopagent-gate 0.4.2__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.
- loopagent_gate-0.4.2.dist-info/METADATA +374 -0
- loopagent_gate-0.4.2.dist-info/RECORD +40 -0
- loopagent_gate-0.4.2.dist-info/WHEEL +5 -0
- loopagent_gate-0.4.2.dist-info/entry_points.txt +2 -0
- loopagent_gate-0.4.2.dist-info/licenses/LICENSE +21 -0
- loopagent_gate-0.4.2.dist-info/top_level.txt +1 -0
- loopgate/__init__.py +7 -0
- loopgate/__main__.py +8 -0
- loopgate/cli.py +147 -0
- loopgate/gates/anchor_witness.py +249 -0
- loopgate/gates/assert_dims.py +88 -0
- loopgate/gates/bootstrap_state.py +111 -0
- loopgate/gates/change_health.py +232 -0
- loopgate/gates/check_loop.py +1267 -0
- loopgate/gates/check_product.py +463 -0
- loopgate/gates/check_visual.py +224 -0
- loopgate/gates/doctor.py +130 -0
- loopgate/gates/estimate_task.py +137 -0
- loopgate/gates/export_public.py +289 -0
- loopgate/gates/guard_card.py +519 -0
- loopgate/gates/loop_watch.py +118 -0
- loopgate/gates/regressions.py +463 -0
- loopgate/gates/render_status_page.py +151 -0
- loopgate/gates/render_task_ledger.py +312 -0
- loopgate/gates/review_pr.py +370 -0
- loopgate/gates/sync_issues_to_kanban.py +593 -0
- loopgate/gates/tree_parity.py +307 -0
- loopgate/gates/worker_brief.py +396 -0
- loopgate/init.py +128 -0
- loopgate/paths.py +43 -0
- loopgate/py.typed +0 -0
- loopgate/templates/__init__.py +1 -0
- loopgate/templates/configs/anchor_witness.example.json +14 -0
- loopgate/templates/configs/fixtures/issues_sample.json +75 -0
- loopgate/templates/configs/fixtures/review_approve.json +5 -0
- loopgate/templates/configs/fixtures/review_changes.json +5 -0
- loopgate/templates/configs/fixtures/review_no_verdict.json +5 -0
- loopgate/templates/configs/repo_profiles.example.json +63 -0
- loopgate/templates/configs/tree_parity.example.json +16 -0
- loopgate/templates/skills/loop-engineering/SKILL.md +281 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Issue bridge — GitHub issues -> Hermes kanban execution queue (PLANS.md stage 6).
|
|
3
|
+
|
|
4
|
+
Roles of the two sides
|
|
5
|
+
----------------------
|
|
6
|
+
* GitHub issues are the **source of truth** and the external feedback channel (testers, operator, chief).
|
|
7
|
+
* The Hermes kanban board is the **local execution queue**: tasks are claimed atomically and executed by
|
|
8
|
+
a named profile in an isolated workspace. The bridge is the only path from one to the other.
|
|
9
|
+
|
|
10
|
+
What the bridge guarantees
|
|
11
|
+
--------------------------
|
|
12
|
+
* **One task = one PR**: one open issue produces exactly one kanban task, whose body is the worker brief
|
|
13
|
+
rendered by ``loopgate/gates/worker_brief.py`` (mandatory evidence bundle included, not implied).
|
|
14
|
+
* **Idempotence**: the task carries the issue-derived idempotency key ``<exporter>-<owner>-<repo>-<n>``;
|
|
15
|
+
a second run reports the task as already queued instead of minting a duplicate. The exporter prefix is
|
|
16
|
+
part of the key, so a file export can never collide with a live ``gh`` key space it does not own.
|
|
17
|
+
* **Fail-closed**: an issue without acceptance criteria is *not* queued (it is reported as needing
|
|
18
|
+
triage), an unknown repository profile is refused, and ``--apply`` refuses to queue work that no
|
|
19
|
+
worker could execute (no workspace/project target configured) — a queue that cannot run is a lie.
|
|
20
|
+
* **Read-only by default**: mutations happen only with ``--apply``. ``--issues-file`` is a *fixture*
|
|
21
|
+
unless whoever exported it says otherwise with ``--exported-by <name>``; an undeclared file can only be
|
|
22
|
+
printed, never applied. A declared file is authoritative for the exporter it names: its keys carry that
|
|
23
|
+
name, ``--target backlog`` is allowed for any exporter, and ``--target kanban`` stays restricted to
|
|
24
|
+
``gh`` exports (a queue that starts unattended workers is a claim about real issue numbers).
|
|
25
|
+
|
|
26
|
+
Left to the triage role (stage 5): the semantic half of validity — whether the reported behaviour is
|
|
27
|
+
reproducible at all. The bridge enforces only the machine-checkable subset (labels, body substance,
|
|
28
|
+
acceptance criteria) and says so in its report.
|
|
29
|
+
|
|
30
|
+
Usage
|
|
31
|
+
-----
|
|
32
|
+
python3 -m loopgate.gates.sync_issues_to_kanban --profile sample-service # dry run, live gh
|
|
33
|
+
python3 -m loopgate.gates.sync_issues_to_kanban --profile sample-service --issues-file configs/fixtures/issues_sample.json
|
|
34
|
+
python3 -m loopgate.gates.sync_issues_to_kanban --profile sample-service --issues-file issues.json \
|
|
35
|
+
--exported-by gh --apply --target backlog # a file that IS the source of truth
|
|
36
|
+
python3 -m loopgate.gates.sync_issues_to_kanban --profile sample-service --apply # queue for real
|
|
37
|
+
python3 -m loopgate.gates.sync_issues_to_kanban --profile sample-service --selftest # prove the write path
|
|
38
|
+
|
|
39
|
+
Exit 0 prints ``ISSUE_BRIDGE_PASS_0001``; a failure prints ``ISSUE_BRIDGE_STOP_0001`` and exits 1.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from loopgate.paths import project_root, gate_file
|
|
45
|
+
|
|
46
|
+
import argparse
|
|
47
|
+
import datetime as dt
|
|
48
|
+
import json
|
|
49
|
+
import os
|
|
50
|
+
import re
|
|
51
|
+
import subprocess
|
|
52
|
+
import sys
|
|
53
|
+
|
|
54
|
+
ROOT = project_root()
|
|
55
|
+
|
|
56
|
+
from loopgate.gates import worker_brief # noqa: E402 (sibling script: the canonical brief + validator)
|
|
57
|
+
|
|
58
|
+
PASS_MARKER = "ISSUE_BRIDGE_PASS_0001"
|
|
59
|
+
STOP_MARKER = "ISSUE_BRIDGE_STOP_0001"
|
|
60
|
+
REPORT_REL = "artifacts/issue_bridge_report.json"
|
|
61
|
+
SELFTEST_KEY = "issue-bridge-selftest"
|
|
62
|
+
SELFTEST_TITLE = "[issue-bridge selftest] write path check"
|
|
63
|
+
MIN_BODY_CHARS = 120
|
|
64
|
+
TITLE_LIMIT = 110
|
|
65
|
+
GH_FIELDS = "number,title,body,labels,url,state"
|
|
66
|
+
ACCEPTANCE_RE = re.compile(
|
|
67
|
+
r"^\s*#{1,4}\s*(acceptance|acceptance criteria|критерии приёмки)\b"
|
|
68
|
+
r"|^\s*\**acceptance criteria\**\s*:",
|
|
69
|
+
re.I | re.M,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------- sources
|
|
74
|
+
|
|
75
|
+
def gh_issues(repo: str, limit: int) -> list[dict]:
|
|
76
|
+
proc = subprocess.run(
|
|
77
|
+
["gh", "issue", "list", "-R", repo, "--state", "open", "--limit", str(limit),
|
|
78
|
+
"--json", GH_FIELDS],
|
|
79
|
+
capture_output=True, text=True,
|
|
80
|
+
)
|
|
81
|
+
if proc.returncode != 0:
|
|
82
|
+
raise RuntimeError(f"gh issue list failed ({proc.returncode}): {proc.stderr.strip()}")
|
|
83
|
+
return [normalize_issue(i) for i in json.loads(proc.stdout or "[]")]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def fixture_issues(path: str) -> list[dict]:
|
|
87
|
+
with open(path, encoding="utf-8") as fh:
|
|
88
|
+
payload = json.load(fh)
|
|
89
|
+
issues = payload["issues"] if isinstance(payload, dict) else payload
|
|
90
|
+
return [normalize_issue(i) for i in issues]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def normalize_issue(raw: dict) -> dict:
|
|
94
|
+
labels = raw.get("labels") or []
|
|
95
|
+
names = [l.get("name", "") if isinstance(l, dict) else str(l) for l in labels]
|
|
96
|
+
return {
|
|
97
|
+
"number": int(raw["number"]),
|
|
98
|
+
"title": (raw.get("title") or "").strip(),
|
|
99
|
+
"body": raw.get("body") or "",
|
|
100
|
+
"url": raw.get("url") or "",
|
|
101
|
+
"labels": [n for n in names if n],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------- triage (machine-checkable subset)
|
|
106
|
+
|
|
107
|
+
def classify(issue: dict, profile: dict) -> tuple[str, str]:
|
|
108
|
+
"""Return ('queue' | 'needs-triage' | 'skip', reason).
|
|
109
|
+
|
|
110
|
+
The reason names the *basis* of the verdict, because the two queue paths are not equally strong: an
|
|
111
|
+
acceptance-criteria section is evidence in the issue itself, while a triage label is the triage
|
|
112
|
+
role's semantic approval — the brief then requires the worker to state the concrete criteria it
|
|
113
|
+
implemented against.
|
|
114
|
+
"""
|
|
115
|
+
labels = {l.lower() for l in issue.get("labels") or []}
|
|
116
|
+
skipped = labels & {str(l).lower() for l in profile.get("skip_labels") or []}
|
|
117
|
+
if skipped:
|
|
118
|
+
return "skip", f"label(s) {', '.join(sorted(skipped))}: not work for this queue"
|
|
119
|
+
body = (issue.get("body") or "").strip()
|
|
120
|
+
if len(body) < MIN_BODY_CHARS:
|
|
121
|
+
return "needs-triage", (f"body is {len(body)} chars (< {MIN_BODY_CHARS}): nothing to reproduce "
|
|
122
|
+
"or to accept")
|
|
123
|
+
triaged = sorted(labels & {str(l).lower() for l in profile.get("triage_labels") or []})
|
|
124
|
+
if ACCEPTANCE_RE.search(body):
|
|
125
|
+
return "queue", "acceptance-criteria section present in the issue"
|
|
126
|
+
if triaged:
|
|
127
|
+
return "queue", (f"triage label {', '.join(triaged)} (triage role approved; the brief requires the "
|
|
128
|
+
"worker to state the concrete criteria)")
|
|
129
|
+
return "needs-triage", "no acceptance-criteria section and no triage label"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------- kanban queue
|
|
133
|
+
|
|
134
|
+
ESTIMATES_REL = "configs/task_estimates.json"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
EXPORTER_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
|
|
138
|
+
# Names that would put a file's keys inside a key space this bridge already reserves. `fixture` is the
|
|
139
|
+
# unapplied source, and `selftest` belongs to the write-path check; neither may become authoritative,
|
|
140
|
+
# otherwise the guard that stops a fixture from shadowing real issue numbers would be removable by a flag.
|
|
141
|
+
RESERVED_EXPORTERS = {"fixture", "selftest", "dry-run", "none"}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def issue_key(profile: dict, number: int, exporter: str = "gh") -> str:
|
|
145
|
+
"""Idempotency key: the exporter is part of the identity, not an afterthought.
|
|
146
|
+
|
|
147
|
+
A `gh` export and the live `gh` path share the `gh-` prefix on purpose — the same issue queued from a
|
|
148
|
+
file and from the CLI must resolve to one task. Any other exporter gets its own prefix, so a Jira or
|
|
149
|
+
spreadsheet export can never shadow a real GitHub issue number.
|
|
150
|
+
"""
|
|
151
|
+
return f"{exporter}-" + profile["repo"].replace("/", "-") + f"-{number}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def exporter_error(name: str) -> str | None:
|
|
155
|
+
"""Why this name cannot declare a file authoritative, or None when it can."""
|
|
156
|
+
slug = (name or "").strip().lower()
|
|
157
|
+
if not slug:
|
|
158
|
+
return "empty name"
|
|
159
|
+
if slug in RESERVED_EXPORTERS:
|
|
160
|
+
return (f"`{slug}` is reserved for the unapplied fixture source; declaring it authoritative would "
|
|
161
|
+
f"put a file's keys in a key space the bridge already owns")
|
|
162
|
+
if not EXPORTER_RE.fullmatch(slug):
|
|
163
|
+
return f"`{name}` is not a usable exporter name (expected lowercase letters, digits and dashes)"
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def load_estimates(root: str = ROOT) -> dict:
|
|
168
|
+
"""Per-assignee timeboxes measured from queue history (loopgate/gates/estimate_task.py writes this file)."""
|
|
169
|
+
try:
|
|
170
|
+
with open(os.path.join(root, ESTIMATES_REL), encoding="utf-8") as fh:
|
|
171
|
+
return json.load(fh).get("assignees") or {}
|
|
172
|
+
except (OSError, json.JSONDecodeError):
|
|
173
|
+
return {}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def assignee_for(issue: dict, profile: dict) -> str:
|
|
177
|
+
"""A role label wins over the profile default: 'qa' issues go to the qa profile, everything else to
|
|
178
|
+
the profile's implementer. One issue, one owner."""
|
|
179
|
+
kb = profile.get("kanban") or {}
|
|
180
|
+
labels = {l.lower() for l in issue.get("labels") or []}
|
|
181
|
+
for label, assignee in (kb.get("assignee_by_label") or {}).items():
|
|
182
|
+
if str(label).lower() in labels:
|
|
183
|
+
return assignee
|
|
184
|
+
return kb.get("assignee") or ""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def task_title(profile_name: str, issue: dict) -> str:
|
|
188
|
+
head = f"[{profile_name}#{issue['number']}] {issue['title']}"
|
|
189
|
+
return head if len(head) <= TITLE_LIMIT else head[:TITLE_LIMIT - 1].rstrip() + "…"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def existing_tasks() -> list[dict]:
|
|
193
|
+
proc = subprocess.run(["hermes", "kanban", "list", "--json"], capture_output=True, text=True)
|
|
194
|
+
if proc.returncode != 0:
|
|
195
|
+
raise RuntimeError(f"hermes kanban list failed ({proc.returncode}): {proc.stderr.strip()}")
|
|
196
|
+
return json.loads(proc.stdout or "[]")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def queued_numbers(tasks: list[dict], profile_name: str) -> dict[int, str]:
|
|
200
|
+
"""Map issue number -> task id for tasks this bridge already created."""
|
|
201
|
+
prefix = f"[{profile_name}#"
|
|
202
|
+
out: dict[int, str] = {}
|
|
203
|
+
for task in tasks:
|
|
204
|
+
title = task.get("title") or ""
|
|
205
|
+
m = re.match(re.escape(prefix) + r"(\d+)\]", title)
|
|
206
|
+
if m:
|
|
207
|
+
out[int(m.group(1))] = task.get("id", "?")
|
|
208
|
+
return out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def kanban_create(title: str, brief_path: str, profile: dict, key: str,
|
|
212
|
+
initial_status: str | None = None, assignee: str | None = None,
|
|
213
|
+
max_runtime: str | None = None) -> dict:
|
|
214
|
+
kb = profile.get("kanban") or {}
|
|
215
|
+
cmd = ["hermes", "kanban", "create", title, "--body-file", brief_path,
|
|
216
|
+
"--idempotency-key", key, "--created-by", "issue-bridge", "--json"]
|
|
217
|
+
assignee = assignee or kb.get("assignee")
|
|
218
|
+
if assignee:
|
|
219
|
+
cmd += ["--assignee", assignee]
|
|
220
|
+
if max_runtime:
|
|
221
|
+
cmd += ["--max-runtime", max_runtime]
|
|
222
|
+
if kb.get("project"):
|
|
223
|
+
cmd += ["--project", kb["project"]]
|
|
224
|
+
if kb.get("workspace"):
|
|
225
|
+
cmd += ["--workspace", kb["workspace"]]
|
|
226
|
+
if kb.get("priority") is not None:
|
|
227
|
+
cmd += ["--priority", str(kb["priority"])]
|
|
228
|
+
if initial_status:
|
|
229
|
+
cmd += ["--initial-status", initial_status]
|
|
230
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
231
|
+
if proc.returncode != 0:
|
|
232
|
+
raise RuntimeError(f"hermes kanban create failed ({proc.returncode}): {proc.stderr.strip()}")
|
|
233
|
+
try:
|
|
234
|
+
return json.loads(proc.stdout)
|
|
235
|
+
except json.JSONDecodeError:
|
|
236
|
+
return {"raw": proc.stdout.strip()}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def backlog_dir(root: str) -> str:
|
|
240
|
+
return os.path.join(root, "backlog")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def backlog_existing(root: str, profile_name: str) -> dict[int, str]:
|
|
244
|
+
"""Issue number -> ledger file name, for cards this bridge already wrote as markdown."""
|
|
245
|
+
prefix = f"[{profile_name}#"
|
|
246
|
+
out: dict[int, str] = {}
|
|
247
|
+
directory = backlog_dir(root)
|
|
248
|
+
if not os.path.isdir(directory):
|
|
249
|
+
return out
|
|
250
|
+
for name in sorted(os.listdir(directory)):
|
|
251
|
+
if not name.endswith(".md"):
|
|
252
|
+
continue
|
|
253
|
+
try:
|
|
254
|
+
with open(os.path.join(directory, name), encoding="utf-8") as fh:
|
|
255
|
+
head = fh.readline()
|
|
256
|
+
except OSError:
|
|
257
|
+
continue
|
|
258
|
+
m = re.search(re.escape(prefix) + r"(\d+)\]", head)
|
|
259
|
+
if m:
|
|
260
|
+
out[int(m.group(1))] = name
|
|
261
|
+
return out
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def backlog_write(root: str, title: str, brief: str, profile: dict, key: str,
|
|
265
|
+
assignee: str | None, max_runtime: str | None) -> dict:
|
|
266
|
+
"""Write the card as a markdown ledger file: the queue works without any execution backend.
|
|
267
|
+
|
|
268
|
+
`--target kanban` hands the card to a dispatcher that runs it; `--target backlog` only records it,
|
|
269
|
+
so a clone without Hermes still turns issues into reviewable, versioned task specs.
|
|
270
|
+
"""
|
|
271
|
+
directory = backlog_dir(root)
|
|
272
|
+
os.makedirs(directory, exist_ok=True)
|
|
273
|
+
path = os.path.join(directory, f"{key}.md")
|
|
274
|
+
number = key.rsplit("-", 1)[-1]
|
|
275
|
+
header = (f"# {title}\n\n"
|
|
276
|
+
f"- issue: {profile['repo']}#{number}\n"
|
|
277
|
+
f"- assignee: {assignee or '(unset)'}\n"
|
|
278
|
+
f"- max-runtime: {max_runtime or '(none)'}\n"
|
|
279
|
+
f"- idempotency-key: {key}\n"
|
|
280
|
+
f"- status: queued\n"
|
|
281
|
+
f"- queued-at: {dt.datetime.now().astimezone().isoformat(timespec='seconds')}\n\n---\n\n")
|
|
282
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
283
|
+
fh.write(header + brief)
|
|
284
|
+
return {"id": os.path.relpath(path, root), "status": "queued"}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
AUTONOMY_REL = "configs/autonomy.json"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def autonomy_config(root: str) -> dict:
|
|
291
|
+
"""The ladder between a human placing every card and the machine placing them overnight.
|
|
292
|
+
|
|
293
|
+
Default is the safest rung, not the most convenient one: a missing or unreadable config means HITL,
|
|
294
|
+
because a bridge that starts queueing work nobody approved is worse than a bridge that asks.
|
|
295
|
+
"""
|
|
296
|
+
path = os.path.join(root, AUTONOMY_REL)
|
|
297
|
+
try:
|
|
298
|
+
with open(path, encoding="utf-8") as fh:
|
|
299
|
+
cfg = json.load(fh)
|
|
300
|
+
except (OSError, json.JSONDecodeError):
|
|
301
|
+
return {"mode": "hitl", "max_cards": 0, "budget_tokens": 0, "source": f"{AUTONOMY_REL} missing or invalid"}
|
|
302
|
+
cfg = cfg if isinstance(cfg, dict) else {}
|
|
303
|
+
return {"mode": str(cfg.get("mode") or "hitl").lower(),
|
|
304
|
+
"max_cards": cfg.get("max_cards"),
|
|
305
|
+
"budget_tokens": cfg.get("budget_tokens"),
|
|
306
|
+
"active": cfg.get("active"),
|
|
307
|
+
"source": AUTONOMY_REL}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def autonomy_refusal(root: str, target: str) -> str | None:
|
|
311
|
+
"""Why --apply is not allowed right now, or None when it is.
|
|
312
|
+
|
|
313
|
+
`backlog` writes a ledger line and starts nothing, so it is allowed at every rung; `kanban` starts a
|
|
314
|
+
worker, and that is what a human has to authorise — or what an AFK config has to bound in advance.
|
|
315
|
+
"""
|
|
316
|
+
if target != "kanban":
|
|
317
|
+
return None
|
|
318
|
+
cfg = autonomy_config(root)
|
|
319
|
+
mode = cfg["mode"]
|
|
320
|
+
if mode == "hitl":
|
|
321
|
+
return ("autonomy is HITL: every card is placed by a human (`hermes kanban create` or the Telegram "
|
|
322
|
+
"gateway). Set mode=afk in configs/autonomy.json with a budget to let the bridge queue work")
|
|
323
|
+
if mode != "afk":
|
|
324
|
+
return f"unknown autonomy mode {mode!r} in configs/autonomy.json (expected 'hitl' or 'afk')"
|
|
325
|
+
budget = cfg["budget_tokens"]
|
|
326
|
+
cards = cfg["max_cards"]
|
|
327
|
+
if not isinstance(budget, int) or isinstance(budget, bool) or budget <= 0:
|
|
328
|
+
return ("afk mode without a token budget: configs/autonomy.json needs budget_tokens > 0, otherwise "
|
|
329
|
+
"an unattended bridge can spend without a ceiling")
|
|
330
|
+
if not isinstance(cards, int) or isinstance(cards, bool) or cards < 1:
|
|
331
|
+
return ("afk mode without max_cards: configs/autonomy.json needs max_cards >= 1, otherwise one poll "
|
|
332
|
+
"can queue the whole issue tracker")
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def apply_target_error(profile: dict) -> str | None:
|
|
337
|
+
"""Refuse to queue work no worker could execute (fail-closed, stage-6 prerequisite)."""
|
|
338
|
+
kb = profile.get("kanban") or {}
|
|
339
|
+
if kb.get("project") or kb.get("workspace"):
|
|
340
|
+
return None
|
|
341
|
+
return ("profile has neither kanban.project nor kanban.workspace: register the target repo as a "
|
|
342
|
+
"Hermes project (`hermes project create`) or set kanban.workspace, otherwise the dispatched "
|
|
343
|
+
"worker gets an empty scratch workspace and the brief's own stop-if fires")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ---------------------------------------------------------------- selftest
|
|
347
|
+
|
|
348
|
+
def selftest(profile: dict) -> int:
|
|
349
|
+
"""Prove the queue write path live: create (blocked) -> show -> archive."""
|
|
350
|
+
brief = "# issue-bridge selftest\n\nNo worker action required; this task exists to prove the bridge's\n" \
|
|
351
|
+
"create/show/archive path and the idempotency key.\n"
|
|
352
|
+
tmp = os.path.join(ROOT, "artifacts", "_issue_bridge_selftest.md")
|
|
353
|
+
os.makedirs(os.path.dirname(tmp), exist_ok=True)
|
|
354
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
355
|
+
fh.write(brief)
|
|
356
|
+
created = kanban_create(SELFTEST_TITLE, tmp, profile, SELFTEST_KEY, initial_status="blocked")
|
|
357
|
+
task_id = created.get("id")
|
|
358
|
+
if not task_id:
|
|
359
|
+
print(f"selftest: create returned no task id: {created}")
|
|
360
|
+
return 1
|
|
361
|
+
show = subprocess.run(["hermes", "kanban", "show", task_id, "--json"],
|
|
362
|
+
capture_output=True, text=True)
|
|
363
|
+
if show.returncode != 0:
|
|
364
|
+
print(f"selftest: kanban show failed: {show.stderr.strip()}")
|
|
365
|
+
return 1
|
|
366
|
+
shown = json.loads(show.stdout)
|
|
367
|
+
task = shown.get("task", shown)
|
|
368
|
+
reuse = kanban_create(SELFTEST_TITLE, tmp, profile, SELFTEST_KEY, initial_status="blocked")
|
|
369
|
+
same = reuse.get("id") == task_id
|
|
370
|
+
print(f"selftest: id={task_id} status={task.get('status')} assignee={task.get('assignee')} "
|
|
371
|
+
f"created_by={task.get('created_by')} body_len={len(task.get('body') or '')}")
|
|
372
|
+
print(f"selftest: second create with the same idempotency key returned {reuse.get('id')} "
|
|
373
|
+
f"(same task: {same})")
|
|
374
|
+
archive = subprocess.run(["hermes", "kanban", "archive", task_id],
|
|
375
|
+
capture_output=True, text=True)
|
|
376
|
+
archived = archive.returncode == 0
|
|
377
|
+
print(f"selftest: archived={archived}")
|
|
378
|
+
os.remove(tmp)
|
|
379
|
+
return 0 if (same and archived and task.get("created_by") == "issue-bridge") else 1
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ---------------------------------------------------------------- main
|
|
383
|
+
|
|
384
|
+
def main(argv=None) -> int:
|
|
385
|
+
ap = argparse.ArgumentParser(description="Bridge GitHub issues into the Hermes kanban queue.")
|
|
386
|
+
ap.add_argument("--profile", required=True, help="repository profile from configs/repo_profiles.json")
|
|
387
|
+
ap.add_argument("--root", default=ROOT)
|
|
388
|
+
ap.add_argument("--limit", type=int, default=30)
|
|
389
|
+
ap.add_argument("--issues-file", help="read issues from a JSON file instead of the GitHub CLI; a file "
|
|
390
|
+
"is a fixture (print-only) until --exported-by marks it as the "
|
|
391
|
+
"authoritative export of a named tracker")
|
|
392
|
+
ap.add_argument("--exported-by", metavar="NAME",
|
|
393
|
+
help="declare the --issues-file an authoritative export made by NAME (e.g. gh, jira); "
|
|
394
|
+
"the name becomes the idempotency-key prefix")
|
|
395
|
+
ap.add_argument("--apply", action="store_true", help="actually create the kanban tasks")
|
|
396
|
+
ap.add_argument("--target", choices=["kanban", "backlog"], default="kanban",
|
|
397
|
+
help="where a card goes: 'kanban' hands it to the Hermes dispatcher, 'backlog' writes "
|
|
398
|
+
"a markdown ledger file under backlog/ (works without any execution backend)")
|
|
399
|
+
ap.add_argument("--selftest", action="store_true", help="prove the kanban write path, then exit")
|
|
400
|
+
ap.add_argument("--report", default=REPORT_REL)
|
|
401
|
+
ap.add_argument("--print-brief", type=int, help="print the rendered brief for this issue number")
|
|
402
|
+
ap.add_argument("--json", action="store_true")
|
|
403
|
+
args = ap.parse_args(argv)
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
profile = worker_brief.get_profile(args.root, args.profile)
|
|
407
|
+
except (KeyError, OSError, json.JSONDecodeError) as exc:
|
|
408
|
+
print(f"profile error: {exc}", file=sys.stderr)
|
|
409
|
+
return 2
|
|
410
|
+
|
|
411
|
+
if args.selftest:
|
|
412
|
+
rc = selftest(profile)
|
|
413
|
+
print(PASS_MARKER if rc == 0 else STOP_MARKER)
|
|
414
|
+
return rc
|
|
415
|
+
|
|
416
|
+
# A file is a fixture until its exporter is declared. The declaration is what changes both the answer
|
|
417
|
+
# to "--apply may I?" and the key space, so a dry run cannot silently become a queue by habit.
|
|
418
|
+
exporter = (args.exported_by or "").strip().lower()
|
|
419
|
+
source = "github" if not args.issues_file else ("file" if exporter else "fixture")
|
|
420
|
+
if args.exported_by and not args.issues_file:
|
|
421
|
+
print("refusing --exported-by without --issues-file: a live `gh` read is already authoritative, "
|
|
422
|
+
"and the flag would give the same issues a second key space", file=sys.stderr)
|
|
423
|
+
print(STOP_MARKER)
|
|
424
|
+
return 1
|
|
425
|
+
export_error = exporter_error(args.exported_by) if args.exported_by else None
|
|
426
|
+
if export_error:
|
|
427
|
+
print(f"refusing --exported-by {args.exported_by!r}: {export_error}", file=sys.stderr)
|
|
428
|
+
print(STOP_MARKER)
|
|
429
|
+
return 1
|
|
430
|
+
try:
|
|
431
|
+
issues = (fixture_issues(args.issues_file) if args.issues_file
|
|
432
|
+
else gh_issues(profile["repo"], args.limit))
|
|
433
|
+
except (OSError, RuntimeError, json.JSONDecodeError) as exc:
|
|
434
|
+
print(f"source error ({source}): {exc}", file=sys.stderr)
|
|
435
|
+
print(STOP_MARKER)
|
|
436
|
+
return 1
|
|
437
|
+
|
|
438
|
+
# Authorization is asked first: a refusal that is about the mode must not be buried under a message
|
|
439
|
+
# about the data source.
|
|
440
|
+
refusal = autonomy_refusal(args.root, args.target) if args.apply else None
|
|
441
|
+
if refusal:
|
|
442
|
+
print(f"refusing --apply: {refusal}", file=sys.stderr)
|
|
443
|
+
print(STOP_MARKER)
|
|
444
|
+
return 1
|
|
445
|
+
|
|
446
|
+
if args.apply and source == "fixture":
|
|
447
|
+
print("refusing --apply with --issues-file: an undeclared file is a fixture, and fixture numbers "
|
|
448
|
+
"are not real issues. Mark it authoritative with --exported-by <name> (e.g. "
|
|
449
|
+
"--exported-by gh for a `gh issue list --json` export) if it is the real source.",
|
|
450
|
+
file=sys.stderr)
|
|
451
|
+
print(STOP_MARKER)
|
|
452
|
+
return 1
|
|
453
|
+
if args.apply and source == "file" and args.target == "kanban" and exporter != "gh":
|
|
454
|
+
print(f"refusing --apply --target kanban for exporter {exporter!r}: starting unattended workers is "
|
|
455
|
+
f"a claim about real issue numbers, so the queue accepts only `--exported-by gh`; use "
|
|
456
|
+
f"--target backlog for other trackers", file=sys.stderr)
|
|
457
|
+
print(STOP_MARKER)
|
|
458
|
+
return 1
|
|
459
|
+
target_error = apply_target_error(profile) if (args.apply and args.target == "kanban") else None
|
|
460
|
+
if target_error:
|
|
461
|
+
print(f"refusing --apply: {target_error}", file=sys.stderr)
|
|
462
|
+
print(STOP_MARKER)
|
|
463
|
+
return 1
|
|
464
|
+
|
|
465
|
+
if args.target == "backlog":
|
|
466
|
+
existing = backlog_existing(args.root, args.profile) if args.apply else {}
|
|
467
|
+
else:
|
|
468
|
+
# Any source whose keys live in the `gh` space must consult the board before minting: a file
|
|
469
|
+
# export is authoritative for the same issue numbers a live read would return, so it needs the
|
|
470
|
+
# same "already queued" answer. Without this the bridge reported QUEUED for work the board had
|
|
471
|
+
# already accepted (found by a live `mode: afk` run, where the kanban target really creates cards).
|
|
472
|
+
dedupe = args.apply and args.target == "kanban" and source in ("github", "file") and (
|
|
473
|
+
source == "github" or exporter == "gh")
|
|
474
|
+
existing = queued_numbers(existing_tasks(), args.profile) if dedupe else {}
|
|
475
|
+
report = {
|
|
476
|
+
"marker": PASS_MARKER,
|
|
477
|
+
"profile": args.profile,
|
|
478
|
+
"repo": profile["repo"],
|
|
479
|
+
"source": source,
|
|
480
|
+
"exported_by": exporter or None,
|
|
481
|
+
"key_prefix": exporter or "gh",
|
|
482
|
+
"mode": "apply" if args.apply else "dry-run",
|
|
483
|
+
"generated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
484
|
+
"evidence_bundle": [i["command"] for i in worker_brief.bundle_commands(profile)],
|
|
485
|
+
"queued": [], "already_queued": [], "needs_triage": [], "skipped": [], "errors": [],
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
print(f"issue bridge — {profile['repo']} via {source} "
|
|
489
|
+
f"({'APPLY' if args.apply else 'dry-run; pass --apply to queue'})")
|
|
490
|
+
print(f"evidence bundle required of every worker: "
|
|
491
|
+
f"{', '.join(i['command'] for i in worker_brief.bundle_commands(profile))}")
|
|
492
|
+
print()
|
|
493
|
+
|
|
494
|
+
printed = 0
|
|
495
|
+
created_count = 0
|
|
496
|
+
for issue in sorted(issues, key=lambda i: i["number"]):
|
|
497
|
+
number, title = issue["number"], issue["title"]
|
|
498
|
+
verdict, reason = classify(issue, profile)
|
|
499
|
+
if verdict == "skip":
|
|
500
|
+
report["skipped"].append({"issue": number, "title": title, "reason": reason})
|
|
501
|
+
print(f" #{number:<5} SKIP {title[:60]} — {reason}")
|
|
502
|
+
continue
|
|
503
|
+
if verdict == "needs-triage":
|
|
504
|
+
report["needs_triage"].append({"issue": number, "title": title, "reason": reason})
|
|
505
|
+
print(f" #{number:<5} NEEDS-TRIAGE {title[:60]} — {reason}")
|
|
506
|
+
continue
|
|
507
|
+
if number in existing:
|
|
508
|
+
report["already_queued"].append({"issue": number, "task": existing[number]})
|
|
509
|
+
print(f" #{number:<5} QUEUED {title[:60]} — task {existing[number]} (idempotent)")
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
brief = worker_brief.render_brief(args.profile, profile, issue)
|
|
513
|
+
key = issue_key(profile, number, exporter or "gh")
|
|
514
|
+
assignee = assignee_for(issue, profile)
|
|
515
|
+
estimate = (load_estimates(args.root).get(assignee) or {})
|
|
516
|
+
cap = estimate.get("max_runtime")
|
|
517
|
+
entry = {"issue": number, "title": title, "idempotency_key": key,
|
|
518
|
+
"assignee": assignee, "max_runtime": cap,
|
|
519
|
+
"estimate_basis": estimate.get("basis"),
|
|
520
|
+
"brief_chars": len(brief)}
|
|
521
|
+
show_brief = args.print_brief == number or (args.print_brief is None and not args.apply
|
|
522
|
+
and not args.json and printed == 0)
|
|
523
|
+
if show_brief:
|
|
524
|
+
printed += 1
|
|
525
|
+
print()
|
|
526
|
+
print(brief)
|
|
527
|
+
print()
|
|
528
|
+
if args.apply and args.target == "kanban":
|
|
529
|
+
cap_cards = autonomy_config(args.root)["max_cards"]
|
|
530
|
+
if isinstance(cap_cards, int) and created_count >= cap_cards:
|
|
531
|
+
print(f" #{number:<5} DEFERRED max_cards={cap_cards} reached: remaining issues wait for "
|
|
532
|
+
f"the next poll")
|
|
533
|
+
report["skipped"].append({"issue": number, "reason": f"max_cards={cap_cards} reached"})
|
|
534
|
+
continue
|
|
535
|
+
if not args.apply:
|
|
536
|
+
entry["planned_task_title"] = task_title(args.profile, issue)
|
|
537
|
+
report["queued"].append(entry)
|
|
538
|
+
print(f" #{number:<5} WOULD-QUEUE {title[:60]} — key {key}")
|
|
539
|
+
continue
|
|
540
|
+
tmp = os.path.join(ROOT, "artifacts", "_bridge_body.md")
|
|
541
|
+
os.makedirs(os.path.dirname(tmp), exist_ok=True)
|
|
542
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
543
|
+
fh.write(brief)
|
|
544
|
+
try:
|
|
545
|
+
created = (backlog_write(args.root, task_title(args.profile, issue), brief, profile, key,
|
|
546
|
+
assignee=assignee, max_runtime=cap)
|
|
547
|
+
if args.target == "backlog" else
|
|
548
|
+
kanban_create(task_title(args.profile, issue), tmp, profile, key,
|
|
549
|
+
assignee=assignee, max_runtime=cap))
|
|
550
|
+
except RuntimeError as exc:
|
|
551
|
+
report["errors"].append({"issue": number, "error": str(exc)})
|
|
552
|
+
print(f" #{number:<5} ERROR {exc}")
|
|
553
|
+
continue
|
|
554
|
+
finally:
|
|
555
|
+
os.remove(tmp)
|
|
556
|
+
if created.get("id") in set(existing.values()) or created.get("status") == "existing":
|
|
557
|
+
# The board answered idempotently: nothing new was minted, so this card must not consume the
|
|
558
|
+
# max_cards budget of this poll either.
|
|
559
|
+
entry["task"] = created.get("id")
|
|
560
|
+
report["already_queued"].append({"issue": number, "task": created.get("id")})
|
|
561
|
+
print(f" #{number:<5} QUEUED {title[:60]} — task {created.get('id')} (idempotent)")
|
|
562
|
+
continue
|
|
563
|
+
created_count += 1
|
|
564
|
+
entry["task"] = created.get("id")
|
|
565
|
+
entry["task_status"] = created.get("status")
|
|
566
|
+
report["queued"].append(entry)
|
|
567
|
+
print(f" #{number:<5} QUEUED {title[:60]} — task {entry['task']} "
|
|
568
|
+
f"({entry['task_status']}, assignee={entry.get('assignee')}, cap={cap or 'none'})")
|
|
569
|
+
|
|
570
|
+
ok = not report["errors"]
|
|
571
|
+
report["marker"] = PASS_MARKER if ok else STOP_MARKER
|
|
572
|
+
report["counts"] = {"queued": len(report["queued"]), "already_queued": len(report["already_queued"]),
|
|
573
|
+
"needs_triage": len(report["needs_triage"]), "skipped": len(report["skipped"]),
|
|
574
|
+
"errors": len(report["errors"])}
|
|
575
|
+
report_path = args.report if os.path.isabs(args.report) else os.path.join(args.root, args.report)
|
|
576
|
+
os.makedirs(os.path.dirname(report_path), exist_ok=True)
|
|
577
|
+
with open(report_path, "w", encoding="utf-8") as fh:
|
|
578
|
+
json.dump(report, fh, ensure_ascii=False, indent=2, sort_keys=True)
|
|
579
|
+
fh.write("\n")
|
|
580
|
+
|
|
581
|
+
counts = report["counts"]
|
|
582
|
+
print()
|
|
583
|
+
print(f"issues: {len(issues)} — queued {counts['queued']}, already queued {counts['already_queued']}, "
|
|
584
|
+
f"needs triage {counts['needs_triage']}, skipped {counts['skipped']}, errors {counts['errors']}")
|
|
585
|
+
print(f"report: {os.path.relpath(report_path, args.root)}")
|
|
586
|
+
if args.json:
|
|
587
|
+
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
|
588
|
+
print(PASS_MARKER if ok else STOP_MARKER)
|
|
589
|
+
return 0 if ok else 1
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
if __name__ == "__main__":
|
|
593
|
+
sys.exit(main())
|