@giamme/forge 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/SKILL.md +22 -3
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +28 -2
  5. package/docs/commands.md +55 -0
  6. package/docs/dataset.md +22 -0
  7. package/docs/decomposed-runs.md +54 -2
  8. package/docs/forge-ui.md +48 -7
  9. package/docs/memory.md +4 -2
  10. package/docs/reviews.md +87 -2
  11. package/forge +31 -1
  12. package/package.json +1 -1
  13. package/references/dataset.md +103 -4
  14. package/references/decompose.md +41 -5
  15. package/references/harnesses.md +43 -1
  16. package/references/ui.md +31 -6
  17. package/scripts/forge-config.py +350 -0
  18. package/scripts/forge-control.py +649 -0
  19. package/scripts/forge-dispatch.sh +235 -121
  20. package/scripts/forge-issues.py +1134 -0
  21. package/scripts/forge-lib-plans.sh +6 -2
  22. package/scripts/forge-lib-review.sh +158 -3
  23. package/scripts/forge-lib-task.sh +358 -41
  24. package/scripts/forge-parallel-options.sh +3 -1
  25. package/scripts/forge-parallel.sh +99 -13
  26. package/scripts/forge-prompt.py +61 -4
  27. package/scripts/forge-route.py +245 -28
  28. package/scripts/forge-runs-rm.py +840 -0
  29. package/scripts/forge-runs.py +19 -1
  30. package/scripts/forge-runtime.py +47 -1
  31. package/scripts/forge-schedule.py +227 -11
  32. package/scripts/forge-ui.py +85 -19
  33. package/scripts/forge_dataset/__init__.py +84 -8
  34. package/scripts/forge_dataset/capture.py +5 -2
  35. package/scripts/forge_dataset/cli.py +44 -1
  36. package/scripts/forge_dataset/export.py +36 -3
  37. package/scripts/forge_dataset/forecast.py +355 -0
  38. package/scripts/forge_dataset/harvest.py +45 -2
  39. package/scripts/forge_dataset/importer.py +431 -17
  40. package/scripts/forge_dataset/reviewers.py +145 -0
  41. package/scripts/forge_dataset/routing.py +1 -0
  42. package/scripts/forge_jev/__init__.py +7 -3
  43. package/scripts/forge_registry.py +183 -26
  44. package/ui/README.md +41 -7
  45. package/ui/docs/api.md +259 -21
  46. package/ui/forge_ui/__main__.py +10 -2
  47. package/ui/forge_ui/control.py +587 -0
  48. package/ui/forge_ui/export.py +17 -3
  49. package/ui/forge_ui/reader.py +210 -12
  50. package/ui/forge_ui/server.py +125 -4
  51. package/ui/forge_ui/static/app.js +104 -18
  52. package/ui/forge_ui/static/cloud3d.js +4 -1
  53. package/ui/forge_ui/static/cloudmodel.js +1 -1
  54. package/ui/forge_ui/static/cloudui.js +13 -4
  55. package/ui/forge_ui/static/controls.js +619 -0
  56. package/ui/forge_ui/static/findingsview.js +438 -0
  57. package/ui/forge_ui/static/graph.js +6 -5
  58. package/ui/forge_ui/static/index.html +7 -1
  59. package/ui/forge_ui/static/leaderboard.js +18 -0
  60. package/ui/forge_ui/static/modelstats.js +19 -1
  61. package/ui/forge_ui/static/panel.js +76 -14
  62. package/ui/forge_ui/static/rundelete.js +388 -0
  63. package/ui/forge_ui/static/runmodel.js +128 -13
  64. package/ui/forge_ui/static/scene3d.js +32 -15
  65. package/ui/forge_ui/static/settings.js +399 -0
  66. package/ui/forge_ui/static/style.css +184 -5
  67. package/ui/forge_ui/static/uikit.js +161 -0
@@ -0,0 +1,1134 @@
1
+ #!/usr/bin/env python3
2
+ """forge issues <run>: file the review findings a run left open as GitHub issues, with plain `gh`.
3
+
4
+ A run can end with findings nobody will act on: CONFIRMED findings `--qa-threshold` tolerated
5
+ (`tasks/<id>/known_issues.md`), PLAUSIBLE findings in a passed task's `qa.last` (with
6
+ `--include-plausible`), and whatever a whole-run review left open. This drafts one issue per
7
+ finding (or per task/run with `--group`) and creates them only with `--apply`.
8
+
9
+ A whole-run review whose integration branch has moved is marked outdated: its findings stay
10
+ listed (a later merge may have fixed only some of them) and the issue names the tasks that
11
+ merged after it. CONFIRMED findings under `## Accepted by a human despite the reviewer's`
12
+ heading are origin `accepted` — a person accepted that verdict — not `tolerated`. A grouped
13
+ issue repeats every distinct acceptance in the group, and the outdated-review sentence of
14
+ every review in it, not only the first finding's.
15
+
16
+ <run> is a plan dir, a task dir, a review dir (`<plan>/final-review`), known_issues.md or qa.last.
17
+ Tasks that did not pass are skipped: their findings still block them.
18
+
19
+ Each issue carries a hidden `<!-- forge:finding fp=... -->` marker. The fingerprint is the one
20
+ gh-proman computes, and both tools skip a finding either of them already filed, in any state.
21
+ When the gh-proman skill is configured for the repository, prefer `gh-proman findings`: it also
22
+ adds the issues to the team's project board with a priority. This command is the fallback.
23
+
24
+ --json lists every finding in `findings` (fp, severity, origin, plan, task, text, `filed`, `review`, ...). --offline never
25
+ runs `gh` (no filed state, publication of commits judged from local refs only). --only FP[,FP] keeps those
26
+ fingerprints. --all-runs reads <run> as a repository (or a plan dir naming one) and collects every run in its
27
+ registry, the newest plan winning for a finding present in several. A caller filing an explicit selection
28
+ passes `--max-issues <len(selection)>`; the default is 10.
29
+
30
+ --dismiss FP[,FP] / --undismiss FP[,FP] (repeatable, with an optional --reason TEXT of at most 500 characters) mark
31
+ findings that need no work, or take the mark back, then exit; they need no network and accept a fingerprint that
32
+ matches no current finding. A dismissed finding stays in `findings` with `dismissed` = {at, reason} (null otherwise)
33
+ and is left out of the drafts unless --include-dismissed is given. The marks live outside the repository, in
34
+ `${XDG_STATE_HOME:-~/.local/state}/forge/findings/<repo_key>.json` (an empty or relative XDG_STATE_HOME counts as
35
+ unset); the store directory and file must not be symlinks, and the file must be a regular one. The repository they
36
+ belong to is the one <run> resolves to; --repo does not change it.
37
+
38
+ The repository is the run's own, whatever form <run> takes: a file or task dir resolves to its plan dir and that plan's
39
+ `repo` file, a solo run dir to its own `repo` file, and --all-runs to the repository it names. Listing, filing and
40
+ (un)dismissing all resolve it the same way, so a mark lands in the store a later listing of the run reads. Only a run
41
+ that records no repository falls back to the current directory, and the output says so (`warnings`).
42
+
43
+ Two processes must not file for one repository at once, or both see a fingerprint as unfiled and create it. --apply
44
+ holds `<repo_key>.lock` (next to the store, `flock`) from the `gh issue list` lookup to the last `gh issue create`,
45
+ and each --dismiss/--undismiss holds it around its read-modify-write. A busy lock is retried for 10 s. Listing
46
+ without --apply takes no lock.
47
+
48
+ Exit codes: 0 ok, 2 usage, `gh` or dismissal store error, 3 busy (another filing or dismissal holds the lock).
49
+ """
50
+ from __future__ import annotations
51
+
52
+ import argparse
53
+ import contextlib
54
+ import errno
55
+ import fcntl
56
+ import hashlib
57
+ import json
58
+ import math
59
+ import os
60
+ import re
61
+ import stat
62
+ import subprocess
63
+ import sys
64
+ import tempfile
65
+ import time
66
+ from pathlib import Path
67
+
68
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
69
+ from forge_registry import repo_key, runs_registry # noqa: E402
70
+
71
+ FINDING_RE = re.compile(r"^\s*[-*]\s*\[(P[0-3])\]\s*\[(CONFIRMED|PLAUSIBLE)\]\s*(\S.*?)\s+[—–]+\s+(.+?)\s*$", re.M)
72
+ # gh-proman writes `gh-proman:finding`, this command `forge:finding`; the fingerprints match.
73
+ FP_MARK_RE = re.compile(r"<!-- (?:gh-proman|forge):finding fp=([0-9a-f]{12})")
74
+ DONE = {"PASS", "MERGED"}
75
+ SEVERITIES = ["P0", "P1", "P2", "P3"]
76
+ FP_RE = re.compile(r"[0-9a-f]{12}")
77
+ LOCK_WAIT = 10.0 # seconds a busy repository lock is retried before `forge issues` gives up (exit 3)
78
+ LOCK_POLL = 0.1
79
+ REASON_MAX = 500
80
+ STORE_MAX = 4 << 20 # a dismissal store is a few hundred bytes per finding; anything bigger is not one
81
+
82
+ TEXT = {
83
+ "en": {
84
+ "plausible": "To verify: ",
85
+ "tolerated": "Forge review finding left open: it was below the blocking threshold {threshold}, so the "
86
+ "work was merged without fixing it.",
87
+ "accepted": "A person accepted the work over the reviewer's {status} verdict, so it was merged without fixing it.",
88
+ "accepted_reason": "Reason: {reason}",
89
+ "review": "Forge whole-run review finding left open: check whether it was already fixed before working on it.",
90
+ "outdated": "The review is outdated: {n} {tasks} merged into the integration branch after it{ids}, "
91
+ "so check whether they fixed it first.",
92
+ "plausible_ctx": "PLAUSIBLE forge review finding (low confidence): verify it before fixing. If it turns out "
93
+ "to be wrong, close the issue saying why.",
94
+ "group": "Forge review findings left open in run `{plan}` ({n}).",
95
+ "group_title": "Non-blocking forge findings: {scope}",
96
+ "group_title_accepted": "Accepted forge findings: {scope}",
97
+ "origin": "Origin: forge `{plan}`, task `{task}`, severity {severity}, reviewed by `{reviewer}`{commit}.",
98
+ "headings": ["Context", "Finding", "Tasks", "Closing criteria", "References"],
99
+ "tasks": ["Reproduce the described case", "Fix it", "Add a regression test that fails before the fix"],
100
+ "tasks_plausible": ["Check whether the problem is real (reproduce or refute it)",
101
+ "If real: fix it and add a regression test"],
102
+ "closing": ["The described case no longer reproduces"],
103
+ "closing_plausible": ["The problem is fixed, or refuted with the reasoning in a comment"],
104
+ "code": "Code",
105
+ "unpublished": "(commit not pushed to GitHub yet)",
106
+ },
107
+ "it": {
108
+ "plausible": "Da verificare: ",
109
+ "tolerated": "Segnalazione della review di forge rimasta aperta: era sotto la soglia bloccante {threshold}, "
110
+ "quindi il lavoro è stato integrato senza correggerla.",
111
+ "accepted": "Una persona ha accettato il lavoro nonostante il verdetto {status} del revisore, quindi è "
112
+ "stato integrato senza correggerlo.",
113
+ "accepted_reason": "Motivo: {reason}",
114
+ "review": "Segnalazione della review complessiva di forge rimasta aperta: verificare se è già stata "
115
+ "corretta prima di lavorarci.",
116
+ "outdated": "La review è obsoleta: {n} {tasks} nel branch di integrazione dopo di essa{ids}, quindi "
117
+ "verifica prima se l'hanno già corretta.",
118
+ "plausible_ctx": "Segnalazione PLAUSIBILE della review di forge (confidenza bassa): va verificata prima di "
119
+ "correggere. Se risulta infondata, chiudi l'issue spiegando perché.",
120
+ "group": "Segnalazioni della review di forge rimaste aperte nel run `{plan}` ({n}).",
121
+ "group_title": "Segnalazioni forge non bloccanti: {scope}",
122
+ "group_title_accepted": "Segnalazioni forge accettate: {scope}",
123
+ "origin": "Origine: forge `{plan}`, task `{task}`, gravità {severity}, review di `{reviewer}`{commit}.",
124
+ "headings": ["Contesto", "Segnalazione", "Attività", "Criteri di chiusura", "Riferimenti"],
125
+ "tasks": ["Riprodurre il caso descritto", "Correggere",
126
+ "Aggiungere un test di regressione che fallisce prima della correzione"],
127
+ "tasks_plausible": ["Verificare se il problema è reale (riprodurlo o smentirlo)",
128
+ "Se reale: correggere e aggiungere un test di regressione"],
129
+ "closing": ["Il caso descritto non si riproduce più"],
130
+ "closing_plausible": ["Il problema è corretto, oppure smentito con motivazione in un commento"],
131
+ "code": "Codice",
132
+ "unpublished": "(commit non ancora pubblicato su GitHub)",
133
+ },
134
+ }
135
+
136
+
137
+ class Fail(Exception):
138
+ pass
139
+
140
+
141
+ class Busy(Fail):
142
+ """The repository lock is held by another filing or dismissal (exit 3, not 2)."""
143
+
144
+
145
+ def read(path: Path) -> str:
146
+ try:
147
+ return path.read_text(encoding="utf-8").strip()
148
+ except OSError:
149
+ return ""
150
+
151
+
152
+ def run(cmd, cwd=None, stdin=None, check=True):
153
+ env = dict(os.environ, LC_ALL="C", GH_PROMPT_DISABLED="1", GH_NO_UPDATE_NOTIFIER="1")
154
+ try:
155
+ res = subprocess.run(cmd, cwd=cwd, input=stdin, capture_output=True, text=True, env=env, timeout=120)
156
+ except FileNotFoundError:
157
+ raise Fail(f"{cmd[0]} is not installed")
158
+ if check and res.returncode != 0:
159
+ raise Fail(f"{' '.join(cmd[:3])} failed: {(res.stderr or res.stdout).strip()[:400]}")
160
+ return res
161
+
162
+
163
+ # ---------------------------------------------------------------------------------------------- collect
164
+
165
+ def clean_markdown(text):
166
+ text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text)
167
+ return re.sub(r"(\*\*|__|`)", "", text).strip()
168
+
169
+
170
+ def parse_findings(text):
171
+ out = []
172
+ for m in FINDING_RE.finditer(text or ""):
173
+ loc = m.group(3).strip()
174
+ link = re.fullmatch(r"\[([^\]]+)\]\([^)]*\)", loc)
175
+ loc = (link.group(1) if link else loc).strip("` ")
176
+ lm = re.match(r"(.+?):(\d+)(?:[-:]\d+)?$", loc)
177
+ out.append({"severity": m.group(1), "confidence": m.group(2), "file": lm.group(1) if lm else loc,
178
+ "line": int(lm.group(2)) if lm else None, "text": clean_markdown(m.group(4)),
179
+ "raw": f"[{m.group(1)}][{m.group(2)}] {loc} — {m.group(4).strip()}"})
180
+ return out
181
+
182
+
183
+ def reviewer_spec(task_dir: Path):
184
+ kv = dict(line.split("=", 1) for line in read(task_dir / "qa.resolved").splitlines() if "=" in line)
185
+ return ":".join(x for x in (kv.get("alias"), kv.get("effort"), kv.get("harness")) if x) or "?"
186
+
187
+
188
+ # `final-review-<epoch>-<pid>` or `integrate-<epoch>-<pid>`. `at` is that whole-second epoch.
189
+ _REVIEW_KIND = re.compile(r"(final-review|integrate)-([0-9]+)-([0-9]+)\Z")
190
+ # What `git rev-parse` writes for a commit: sha1 (40) or sha256 (64), lowercase hex.
191
+ _COMMIT = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z")
192
+ # accept appends the reviewer's reply under this heading; findings before it were threshold-tolerated.
193
+ _ACCEPTED_HEADING = re.compile(
194
+ r"^## Accepted by a human despite the reviewer's(?:\s+(\S+))?\s+verdict\s*$", re.M)
195
+
196
+
197
+ def _epoch(text):
198
+ if len(text) > 15:
199
+ return None
200
+ try:
201
+ at = float(text)
202
+ except (ValueError, OverflowError):
203
+ return None
204
+ return at if math.isfinite(at) else None
205
+
206
+
207
+ def review_clock(review_dir: Path):
208
+ """(source, at) of a whole-run review directory.
209
+
210
+ `final-review-<epoch>-<pid>` is source `review`. `integrate-<epoch>-<pid>/final-review` is
211
+ source `integrate`, and `at` is the epoch in the integrate directory's name. A directory
212
+ literally named `final-review` with no such parent is a review whose clock is unknown.
213
+ """
214
+ review_dir = Path(review_dir).resolve()
215
+ if review_dir.name == "final-review":
216
+ match = _REVIEW_KIND.fullmatch(review_dir.parent.name)
217
+ if match and match.group(1) == "integrate":
218
+ return "integrate", _epoch(match.group(2))
219
+ return "review", None
220
+ match = _REVIEW_KIND.fullmatch(review_dir.name)
221
+ if match and match.group(1) == "final-review":
222
+ return "review", _epoch(match.group(2))
223
+ return "review", None
224
+
225
+
226
+ def review_relname(plan_dir, review_dir: Path):
227
+ """The review directory's path relative to the plan, posix, after resolving links."""
228
+ review_dir = Path(review_dir).resolve()
229
+ if plan_dir:
230
+ try:
231
+ return review_dir.relative_to(Path(plan_dir).resolve()).as_posix()
232
+ except ValueError:
233
+ pass
234
+ return review_dir.name
235
+
236
+
237
+ def _meta_fields(review_dir: Path):
238
+ fields = {}
239
+ for line in read(review_dir / "review.meta").splitlines():
240
+ key, sep, value = line.partition("=")
241
+ if sep and key.strip():
242
+ fields[key.strip()] = value.strip()
243
+ return fields
244
+
245
+
246
+ def _known_integration(meta):
247
+ """The `integration=` commit, or None when it is missing or not a commit object name.
248
+
249
+ A malformed value is not the commit the review judged, so freshness uses the next rule.
250
+ """
251
+ value = meta.get("integration") or None
252
+ if not value:
253
+ return None
254
+ return value if _COMMIT.fullmatch(value) else None
255
+
256
+
257
+ def _task_id_ok(name: str) -> bool:
258
+ """True for a task id the scheduler and the UI reader both accept.
259
+
260
+ Empty, `.`, `..`, a slash, a NUL or U+FFFD are not ids. An id may start with `_`.
261
+ """
262
+ return bool(name) and name not in (".", "..") and "/" not in name and "\0" not in name and "\ufffd" not in name
263
+
264
+
265
+ def _listed_task_ids(plan_dir: Path):
266
+ """Task ids from tasks.tsv, in file order.
267
+
268
+ Only those rows are tasks: `tasks/` also holds forge's own directories (for example
269
+ `tasks/_integration`), which are not tasks unless a row lists them. A row needs seven
270
+ columns, the same minimum the UI reader requires. The first of a duplicate id wins.
271
+ """
272
+ found, seen = [], set()
273
+ for line in read(Path(plan_dir) / "tasks.tsv").splitlines():
274
+ if not line.strip() or line.startswith("#"):
275
+ continue
276
+ parts = line.split("\t")
277
+ if len(parts) < 7:
278
+ continue
279
+ ident = parts[0]
280
+ if not _task_id_ok(ident) or ident in seen:
281
+ continue
282
+ seen.add(ident)
283
+ found.append(ident)
284
+ return found
285
+
286
+
287
+ def _task_merges(plan_dir: Path):
288
+ """(id, mtime) of every listed task's `merged` marker, oldest merge first, then id.
289
+
290
+ The ids are `_listed_task_ids`: a listed `_fix` counts, and an unlisted `tasks/_integration`
291
+ does not. This is the set whose merge times the UI uses for `merged_since`.
292
+ """
293
+ root = Path(plan_dir) / "tasks"
294
+ pairs = []
295
+ for ident in _listed_task_ids(plan_dir):
296
+ marker = root / ident / "merged"
297
+ try:
298
+ if marker.is_file():
299
+ pairs.append((ident, marker.stat().st_mtime))
300
+ except OSError:
301
+ continue
302
+ pairs.sort(key=lambda item: (item[1], item[0]))
303
+ return pairs
304
+
305
+
306
+ def _first_line(path: Path):
307
+ text = read(path)
308
+ return text.splitlines()[0].strip() if text else ""
309
+
310
+
311
+ def review_freshness(accepted, integration, tip, source, at, merges, verdict):
312
+ """(outdated, merged_since) for one final review. Same rule as forge-ui's FinalReviewSummary.
313
+
314
+ Null when the review has no verdict or the plan has no `accepted.integration`. Otherwise the
315
+ first rule that applies: a known `integration` commit, else `tip` when `source` is `review`,
316
+ else (an older integrate review) any task merged after `at`. A merge counts when its marker
317
+ mtime is `>= at + 1`. `merged_since` is those ids, oldest first, and is empty unless the
318
+ review is outdated.
319
+ """
320
+ later = []
321
+ if at is not None:
322
+ later = [item for item in merges if item[1] is not None and item[1] >= at + 1]
323
+ later.sort(key=lambda item: (item[1], item[0]))
324
+ ids = [task_id for task_id, _merged_at in later]
325
+ if not verdict or not accepted:
326
+ return None, []
327
+ if integration:
328
+ moved = integration != accepted
329
+ elif source == "review":
330
+ moved = tip != accepted
331
+ else:
332
+ moved = bool(ids)
333
+ return (True, ids) if moved else (False, [])
334
+
335
+
336
+ def review_outdated(plan_dir, review_dir):
337
+ """(outdated, merged_since) for the whole-run review directory `review_dir` in `plan_dir`."""
338
+ if not plan_dir or not review_dir:
339
+ return None, []
340
+ review_dir = Path(review_dir)
341
+ meta = _meta_fields(review_dir)
342
+ source, at = review_clock(review_dir)
343
+ verdict = (_first_line(review_dir / "verdict").split() or [""])[0]
344
+ return review_freshness(_first_line(Path(plan_dir) / "accepted.integration"), _known_integration(meta),
345
+ meta.get("tip") or None, source, at, _task_merges(plan_dir), verdict)
346
+
347
+
348
+ def review_stamp(plan_dir, review_dir):
349
+ """The `review` object a whole-run finding carries, or None when there is no review directory."""
350
+ if not review_dir:
351
+ return None
352
+ outdated, merged_since = review_outdated(plan_dir, review_dir)
353
+ return {"name": review_relname(plan_dir, review_dir), "outdated": outdated, "merged_since": merged_since}
354
+
355
+
356
+ def acceptance_of(task_dir):
357
+ """(status, reason) recorded in `accepted.by`.
358
+
359
+ Key=value (`status`, `reason`, …) is the current record. Free text, which older runs wrote,
360
+ is a reason and no status. `reason` is cut to REASON_MAX characters.
361
+ """
362
+ if not task_dir:
363
+ return "", ""
364
+ raw = read(Path(task_dir) / "accepted.by")
365
+ if not raw:
366
+ return "", ""
367
+ fields = {}
368
+ for line in raw.splitlines():
369
+ if not line.strip():
370
+ continue
371
+ if "=" not in line:
372
+ return "", raw[:REASON_MAX]
373
+ key, value = line.split("=", 1)
374
+ key = key.strip()
375
+ if not key or re.search(r"\s", key):
376
+ return "", raw[:REASON_MAX]
377
+ fields[key] = value.strip()
378
+ if not fields:
379
+ return "", raw[:REASON_MAX]
380
+ return fields.get("status", ""), fields.get("reason", "")[:REASON_MAX]
381
+
382
+
383
+ def known_findings(text, task_dir, meta):
384
+ """CONFIRMED findings of a task's known_issues.md, split at the acceptance heading.
385
+
386
+ Findings before `## Accepted by a human despite the reviewer's <status> verdict` stay
387
+ `tolerated`. Findings after it are `accepted`. The heading's status is used only when
388
+ `accepted.by` does not record one.
389
+ """
390
+ text = text or ""
391
+ match = _ACCEPTED_HEADING.search(text)
392
+ before, after = (text[:match.start()], text[match.end():]) if match else (text, "")
393
+ found = [{**f, **meta, "origin": "tolerated", "review": None}
394
+ for f in parse_findings(before) if f["confidence"] == "CONFIRMED"]
395
+ if match:
396
+ file_status, reason = acceptance_of(task_dir)
397
+ status = file_status or match.group(1) or "UNKNOWN"
398
+ found += [{**f, **meta, "origin": "accepted", "review": None,
399
+ "accepted_status": status, "accepted_reason": reason}
400
+ for f in parse_findings(after) if f["confidence"] == "CONFIRMED"]
401
+ return found
402
+
403
+
404
+ def task_findings(task_dir: Path, plan_dir, include_plausible, reviewers, review=None):
405
+ """`review` True: task_dir is a whole-run review dir (it may hold a known_issues.md when a threshold applied)."""
406
+ status = (read(task_dir / "status").split() or [""])[0]
407
+ results = {}
408
+ if plan_dir and (plan_dir / "results.tsv").is_file():
409
+ for line in read(plan_dir / "results.tsv").splitlines():
410
+ cols = line.split("\t")
411
+ if len(cols) >= 2:
412
+ results[cols[0]] = cols[1]
413
+ task = task_dir.name
414
+ state = results.get(task, status)
415
+ meta = {"task": task, "commit": read(task_dir / "merged") or read(task_dir / "reviewed.commit"),
416
+ "reviewer": reviewers.get(task) or reviewer_spec(task_dir)}
417
+ whole_run = review is True
418
+ if review is None:
419
+ review = not status and task not in results and not (task_dir / "known_issues.md").exists()
420
+ if review:
421
+ # A review with no task behind it (whole-run review): everything it found is still open.
422
+ stamp = review_stamp(plan_dir, task_dir) if whole_run else None
423
+ found = [{**f, **meta, "task": None if whole_run else task,
424
+ "origin": "review" if f["confidence"] == "CONFIRMED" else "plausible",
425
+ "threshold": "-", "review": stamp}
426
+ for f in parse_findings(read(task_dir / "qa.last"))
427
+ if f["confidence"] == "CONFIRMED" or include_plausible]
428
+ return found, None
429
+ if state not in DONE and status not in DONE:
430
+ return [], f"{task}: status {state or 'unknown'}, its findings still block it"
431
+ threshold = read(task_dir / "qa_threshold") or (read(plan_dir / "qa_threshold") if plan_dir else "") or "?"
432
+ meta["threshold"] = threshold
433
+ found = known_findings(read(task_dir / "known_issues.md"), task_dir, meta)
434
+ if include_plausible:
435
+ found += [{**f, **meta, "origin": "plausible", "review": None}
436
+ for f in parse_findings(read(task_dir / "qa.last")) if f["confidence"] == "PLAUSIBLE"]
437
+ return found, None
438
+
439
+
440
+ def mtime(path: Path):
441
+ try:
442
+ return path.stat().st_mtime
443
+ except OSError:
444
+ return 0.0
445
+
446
+
447
+ def whole_run_review(plan_dir: Path):
448
+ """The plan's canonical latest whole-run review dir, or None.
449
+
450
+ `review` publishes `<plan>/final-review` (a link to the newest `final-review-<epoch>-<pid>/`), and
451
+ `integrate --final-review` writes `integrate-*/final-review/` without touching it unless it publishes.
452
+ Any other final-review-* dir is superseded.
453
+ """
454
+ def usable(d):
455
+ return d if (d / "qa.last").is_file() else None
456
+ link = (plan_dir / "final-review").resolve() if (plan_dir / "final-review").is_dir() else None
457
+ published = usable(link) if link else None
458
+ # A published review without qa.last (infra stop, dispatch start failure) is still the latest attempt:
459
+ # an integrate review must beat its own mtime, and nothing older may stand in for it.
460
+ stamp = (mtime(published / "qa.last") if published else
461
+ max(mtime(link / "qa.input"), mtime(link)) if link else None)
462
+ newest = None
463
+ for d in plan_dir.glob("integrate-*/final-review"):
464
+ d = d.resolve()
465
+ if usable(d) and (newest is None or mtime(d / "qa.last") > mtime(newest / "qa.last")):
466
+ newest = d
467
+ if newest and (stamp is None or (newest != link and mtime(newest / "qa.last") > stamp)):
468
+ return newest
469
+ return published
470
+
471
+
472
+ def collect_plan(plan_dir: Path, include_plausible):
473
+ tasks = sorted(d for d in (plan_dir / "tasks").iterdir() if d.is_dir() and not d.name.startswith("_"))
474
+ reviewers = {}
475
+ if (plan_dir / "tasks.tsv").is_file():
476
+ for line in read(plan_dir / "tasks.tsv").splitlines():
477
+ cols = line.split("\t")
478
+ if len(cols) >= 6 and not line.startswith("#"):
479
+ reviewers[cols[0]] = cols[5]
480
+ found, notes = [], []
481
+ for task_dir in tasks:
482
+ items, note = task_findings(task_dir, plan_dir, include_plausible, reviewers)
483
+ found += items
484
+ if note:
485
+ notes.append(note)
486
+ review = whole_run_review(plan_dir)
487
+ if review:
488
+ items, _ = task_findings(review, plan_dir, include_plausible, {}, review=True)
489
+ found += [{**f, "task": None} for f in items]
490
+ for f in found:
491
+ f["plan"], f["plan_dir"] = plan_dir.name, str(plan_dir)
492
+ return found, notes
493
+
494
+
495
+ def collect_all(path: Path, include_plausible):
496
+ plan_file = path / "repo"
497
+ repo = Path(read(plan_file)) if plan_file.is_file() else path
498
+ registry = runs_registry(repo)
499
+ lines = read(registry).splitlines() if registry.is_file() else []
500
+ plans, notes = [], []
501
+ for line in lines:
502
+ line = line.strip()
503
+ if not line or line.startswith("#"):
504
+ continue
505
+ d = Path(line)
506
+ if not d.is_dir():
507
+ notes.append(f"{line}: run directory is missing, skipped")
508
+ elif not (d / "tasks").is_dir():
509
+ notes.append(f"{line}: no tasks/ directory, skipped")
510
+ elif d not in plans:
511
+ plans.append(d)
512
+ plans.sort(key=lambda d: mtime(d), reverse=True) # newest first: the first occurrence of a fingerprint wins
513
+ found = []
514
+ for d in plans:
515
+ items, plan_notes = collect_plan(d, include_plausible)
516
+ found += items
517
+ notes += [f"{d.name}: {n}" for n in plan_notes]
518
+ return {"plan": f"{repo.name} (all runs)", "repo_path": str(repo)}, found, notes
519
+
520
+
521
+ def recorded_repo(run_dir: Path):
522
+ """The repository a plan dir or solo run dir records in its `repo` file; None when it records none."""
523
+ return read(run_dir / "repo") or None
524
+
525
+
526
+ def collect(path: Path, include_plausible, all_runs=False):
527
+ """(meta, findings, notes); meta["repo_path"] is the run's recorded repository, None when it records none."""
528
+ path = path.resolve()
529
+ if all_runs:
530
+ if not path.is_dir():
531
+ raise Fail(f"{path}: --all-runs needs a repository or plan directory")
532
+ return collect_all(path, include_plausible)
533
+ if path.is_file():
534
+ if path.name not in ("known_issues.md", "qa.last"):
535
+ raise Fail(f"{path}: expected a forge plan or task directory, known_issues.md or qa.last")
536
+ parent = path.parent
537
+ is_task = parent.parent.name == "tasks"
538
+ if is_task:
539
+ plan_root = parent.parent.parent
540
+ elif (parent / "tasks").is_dir():
541
+ plan_root = parent
542
+ elif parent.parent.name.startswith("integrate-") and (parent.parent.parent / "tasks").is_dir():
543
+ plan_root = parent.parent.parent
544
+ elif (parent.parent / "tasks").is_dir():
545
+ plan_root = parent.parent
546
+ else:
547
+ plan_root = parent
548
+ is_review = not is_task and not read(parent / "status")
549
+ meta = {"task": None if is_review else parent.name, "threshold": "-" if is_review else read(parent / "qa_threshold") or "?",
550
+ "commit": read(parent / "merged") or read(parent / "reviewed.commit"),
551
+ "reviewer": reviewer_spec(parent)}
552
+ meta["plan"], meta["plan_dir"] = (plan_root.name, str(plan_root))
553
+ if is_review:
554
+ stamp = review_stamp(plan_root, parent)
555
+ found = [{**f, **meta, "origin": "review" if f["confidence"] == "CONFIRMED" else "plausible", "review": stamp}
556
+ for f in parse_findings(read(path)) if f["confidence"] == "CONFIRMED" or include_plausible]
557
+ elif path.name == "known_issues.md":
558
+ found = known_findings(read(path), parent, meta)
559
+ if include_plausible:
560
+ found += [{**f, **meta, "origin": "plausible", "review": None}
561
+ for f in parse_findings(read(path)) if f["confidence"] == "PLAUSIBLE"]
562
+ else:
563
+ found = [{**f, **meta, "origin": "tolerated" if f["confidence"] == "CONFIRMED" else "plausible", "review": None}
564
+ for f in parse_findings(read(path)) if f["confidence"] == "CONFIRMED" or include_plausible]
565
+ # plan_root is the plan dir, or for a file outside any plan (a solo run's) the dir holding it
566
+ return {"plan": plan_root.name, "repo_path": recorded_repo(plan_root)}, found, []
567
+ if (path / "tasks").is_dir():
568
+ found, notes = collect_plan(path, include_plausible)
569
+ return {"plan": path.name, "repo_path": recorded_repo(path)}, found, notes
570
+ if not ((path / "qa.last").is_file() or (path / "known_issues.md").is_file()):
571
+ raise Fail(f"{path} is not a forge run: no tasks/, qa.last or known_issues.md")
572
+ if path.parent.name == "tasks":
573
+ plan_dir = path.parent.parent
574
+ elif (path.parent / "tasks").is_dir():
575
+ plan_dir = path.parent
576
+ elif path.parent.name.startswith("integrate-") and (path.parent.parent / "tasks").is_dir():
577
+ plan_dir = path.parent.parent
578
+ else:
579
+ plan_dir = None
580
+ reviewers = {}
581
+ if plan_dir and (plan_dir / "tasks.tsv").is_file():
582
+ for line in read(plan_dir / "tasks.tsv").splitlines():
583
+ cols = line.split("\t")
584
+ if len(cols) >= 6 and not line.startswith("#"):
585
+ reviewers[cols[0]] = cols[5]
586
+ found, note = task_findings(path, plan_dir, include_plausible, reviewers,
587
+ review=True if path.parent.name != "tasks" and not read(path / "status") else None)
588
+ for f in found:
589
+ f["plan"], f["plan_dir"] = (plan_dir or path).name, str(plan_dir or path)
590
+ # a task or review dir in a plan: the plan's repository; a solo run dir: its own
591
+ return {"plan": (plan_dir or path).name, "repo_path": recorded_repo(plan_dir or path)}, \
592
+ found, [note] if note else []
593
+
594
+
595
+ def run_repository(meta):
596
+ """(repo_path, warnings): the run's recorded repository, else the current directory with a warning saying so.
597
+
598
+ Listing, filing and (un)dismissing all go through here, so they agree on the dismissal store.
599
+ """
600
+ if meta["repo_path"]:
601
+ return meta["repo_path"], []
602
+ cwd = os.getcwd()
603
+ return cwd, [f"run {meta['plan']} records no repository; using the current directory {cwd}"]
604
+
605
+
606
+ # ---------------------------------------------------------------------------------------------- draft
607
+
608
+ def fingerprint(slug, f):
609
+ """Identical to gh-proman's finding_fp: keep the two in step."""
610
+ norm = re.sub(r"\s+", " ", f["text"].lower())[:160]
611
+ return hashlib.sha1(f"{slug.lower()}|{f['file']}|{norm}".encode("utf-8")).hexdigest()[:12]
612
+
613
+
614
+ def github_slug(repo_path):
615
+ res = run(["git", "-C", repo_path, "remote", "get-url", "origin"], check=False)
616
+ m = re.search(r"github\.com[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?/?$", res.stdout.strip())
617
+ return f"{m.group(1)}/{m.group(2)}" if m else None
618
+
619
+
620
+ def title_for(f, lang):
621
+ first = re.split(r"(?<=[.!?])\s", f["text"], maxsplit=1)[0].rstrip(". ")
622
+ if len(first) > 90:
623
+ first = first[:87].rsplit(" ", 1)[0] + "…"
624
+ prefix = TEXT[lang]["plausible"] if f["origin"] == "plausible" else ""
625
+ return f"[{f['severity']}] {prefix}{os.path.basename(f['file'])}: {first}"
626
+
627
+
628
+ def escape(text):
629
+ return text.replace("<!--", "&lt;!--").replace("-->", "--&gt;")
630
+
631
+
632
+ def published(slug, repo_path, commit, offline=False):
633
+ """True when the commit is on GitHub; with `offline`, only remote-tracking refs can prove it."""
634
+ if repo_path and run(["git", "-C", repo_path, "branch", "-r", "--contains", commit], check=False).stdout.strip():
635
+ return True
636
+ if offline:
637
+ return False
638
+ return run(["gh", "api", f"repos/{slug}/commits/{commit}", "--jq", ".sha"], check=False).returncode == 0
639
+
640
+
641
+ def merged_since_text(ids, lang):
642
+ """Ids cut after 5, as `(a, b, c, d, e, +N more)`, or "" when there are none."""
643
+ shown = [str(item) for item in ids[:5]]
644
+ extra = len(ids) - len(shown)
645
+ names = ", ".join(shown)
646
+ if extra:
647
+ more = f"+{extra} altri" if lang == "it" else f"+{extra} more"
648
+ names = f"{names}, {more}" if names else more
649
+ return f" ({names})" if names else ""
650
+
651
+
652
+ def outdated_line(lang, review):
653
+ """The sentence an outdated whole-run review adds after the context line, or ""."""
654
+ if not review or review.get("outdated") is not True:
655
+ return ""
656
+ ids = list(review.get("merged_since") or [])
657
+ n = len(ids)
658
+ if lang == "it":
659
+ tasks = "task è stato integrato" if n == 1 else "task sono stati integrati"
660
+ else:
661
+ tasks = "task" if n == 1 else "tasks"
662
+ return TEXT[lang]["outdated"].format(n=n, tasks=tasks, ids=merged_since_text(ids, lang))
663
+
664
+
665
+ def accepted_line(lang, finding):
666
+ """The sentence for a finding a person accepted, including the recorded reason, or ""."""
667
+ if not finding or finding.get("origin") != "accepted":
668
+ return ""
669
+ t = TEXT[lang]
670
+ line = t["accepted"].format(status=finding.get("accepted_status") or "UNKNOWN")
671
+ reason = finding.get("accepted_reason") or ""
672
+ if reason:
673
+ line += " " + t["accepted_reason"].format(reason=escape(reason))
674
+ return line
675
+
676
+
677
+ def acceptance_lines(lang, items):
678
+ """One sentence per distinct acceptance, in first-seen order.
679
+
680
+ `--group run` puts every accepted finding of the plan in one issue. Tasks record their
681
+ own verdict and reason (`accepted.by`); each distinct pair is its own sentence, so a
682
+ later task's acceptance is not replaced by the first finding's.
683
+ """
684
+ lines, seen = [], set()
685
+ for finding in items:
686
+ line = accepted_line(lang, finding)
687
+ if line and line not in seen:
688
+ seen.add(line)
689
+ lines.append(line)
690
+ return lines
691
+
692
+
693
+ def outdated_lines(lang, items):
694
+ """The outdated-review sentence for every distinct review in the group, in first-seen order.
695
+
696
+ Severity order can put a task's PLAUSIBLE finding ahead of the outdated review's, and
697
+ that first finding has no review. The warning still follows the context line, once per
698
+ distinct sentence.
699
+ """
700
+ lines, seen = [], set()
701
+ for finding in items:
702
+ line = outdated_line(lang, finding.get("review") if finding else None)
703
+ if line and line not in seen:
704
+ seen.add(line)
705
+ lines.append(line)
706
+ return lines
707
+
708
+
709
+ def body_for(slug, repo_path, items, lang, offline=False):
710
+ t = TEXT[lang]
711
+ h = t["headings"]
712
+ first = items[0]
713
+ plan = first["plan"]
714
+ plausible = all(f["origin"] == "plausible" for f in items)
715
+ accepted = acceptance_lines(lang, items)
716
+ if len(items) > 1:
717
+ context = t["group"].format(plan=plan, n=len(items))
718
+ elif plausible:
719
+ context = t["plausible_ctx"]
720
+ elif first["origin"] == "review":
721
+ context = t["review"]
722
+ elif accepted:
723
+ context = accepted[0]
724
+ else:
725
+ context = t["tolerated"].format(threshold=first["threshold"])
726
+ commit = f", commit `{first['commit'][:12]}`" if first.get("commit") else ""
727
+ lines = [f"## {h[0]}", context]
728
+ # A grouped issue's context line is the group intro, so every distinct acceptance follows it.
729
+ if len(items) > 1:
730
+ lines.extend(accepted)
731
+ lines.extend(outdated_lines(lang, items))
732
+ lines += ["",
733
+ t["origin"].format(plan=plan, task=first["task"] or "final-review", severity=first["severity"],
734
+ reviewer=first["reviewer"], commit=commit), "", f"## {h[1]}"]
735
+ for f in items:
736
+ lines += [f"> {escape(f['raw'])}", ""]
737
+ lines.append(f"## {h[2]}")
738
+ if len(items) == 1:
739
+ lines += [f"- [ ] {x}" for x in (t["tasks_plausible"] if plausible else t["tasks"])]
740
+ else:
741
+ lines += [f"- [ ] [{f['severity']}] `{f['file']}{':' + str(f['line']) if f['line'] else ''}`: "
742
+ f"{escape(f['text'])[:160]}" for f in items]
743
+ lines += ["", f"## {h[3]}"] + [f"- [ ] {x}" for x in (t["closing_plausible"] if plausible else t["closing"])]
744
+ lines += ["", f"## {h[4]}"]
745
+ for f in items:
746
+ loc = f"{f['file']}{':' + str(f['line']) if f['line'] else ''}"
747
+ if f.get("commit") and published(slug, repo_path, f["commit"], offline):
748
+ anchor = f"#L{f['line']}" if f["line"] else ""
749
+ lines.append(f"- {t['code']}: https://github.com/{slug}/blob/{f['commit']}/{f['file']}{anchor}")
750
+ else:
751
+ lines.append(f"- {t['code']}: `{loc}` {t['unpublished'] if f.get('commit') else ''}".rstrip())
752
+ lines.append("")
753
+ lines += [f"<!-- forge:finding fp={f['fp']} source=forge:{f['plan']}/{f['task'] or 'final-review'} -->" for f in items]
754
+ return "\n".join(lines)
755
+
756
+
757
+ def existing_issues(slug):
758
+ res = run(["gh", "issue", "list", "--repo", slug, "--state", "all", "--limit", "1000",
759
+ "--json", "number,url,state,title,body"])
760
+ return json.loads(res.stdout or "[]")
761
+
762
+
763
+ def norm_title(title):
764
+ return re.sub(r"\W+", " ", re.sub(r"^\[P[0-3]\]\s*", "", title).lower()).strip()
765
+
766
+
767
+ # ---------------------------------------------------------------------------------------------- state
768
+
769
+ def findings_dir() -> Path:
770
+ """`${XDG_STATE_HOME:-~/.local/state}/forge/findings`; an empty or relative XDG_STATE_HOME counts as unset.
771
+
772
+ Forge keeps its own data out of the user's tree, and a relative state home would name a directory below
773
+ the current one, usually the repository.
774
+ """
775
+ for candidate in (os.environ.get("XDG_STATE_HOME") or "", "~/.local/state"):
776
+ try:
777
+ base = Path(candidate).expanduser() if candidate else None
778
+ except RuntimeError: # no home directory to expand "~" against
779
+ base = None
780
+ if base is not None and base.is_absolute():
781
+ return base / "forge" / "findings"
782
+ raise Fail("cannot place the dismissal store: no home directory and no absolute XDG_STATE_HOME")
783
+
784
+
785
+ def store_dir(create):
786
+ """The findings directory after an lstat check that it is a real directory, not a symlink; None when absent."""
787
+ path = findings_dir()
788
+ if create:
789
+ try:
790
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
791
+ except OSError as exc:
792
+ raise Fail(f"{path}: cannot create the dismissal store directory: {exc.strerror or exc}")
793
+ try:
794
+ mode = os.lstat(path).st_mode
795
+ except FileNotFoundError:
796
+ if create:
797
+ raise Fail(f"{path}: the dismissal store directory vanished")
798
+ return None
799
+ except OSError as exc:
800
+ raise Fail(f"{path}: cannot use the dismissal store directory: {exc.strerror}")
801
+ if not stat.S_ISDIR(mode):
802
+ raise Fail(f"{path}: the dismissal store directory must be a real directory, not a symlink")
803
+ return path
804
+
805
+
806
+ def load_dismissed(key):
807
+ """The repository's dismissals, `{fp: {"at": epoch, "reason": str}}`; empty when it has none yet."""
808
+ base = store_dir(create=False)
809
+ if base is None:
810
+ return {}
811
+ path = base / f"{key}.json"
812
+ not_regular = f"{path}: the dismissal store must be a regular file, not a symlink or special file"
813
+ try:
814
+ if not stat.S_ISREG(os.lstat(path).st_mode):
815
+ raise Fail(not_regular)
816
+ # NOFOLLOW again for a swap after the lstat; NONBLOCK so a FIFO cannot hold the open
817
+ with os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK), "rb") as fh:
818
+ if not stat.S_ISREG(os.fstat(fh.fileno()).st_mode):
819
+ raise Fail(not_regular)
820
+ raw = fh.read(STORE_MAX + 1)
821
+ except FileNotFoundError:
822
+ return {}
823
+ except OSError as exc:
824
+ raise Fail(f"{path}: cannot read the dismissal store: {exc.strerror}")
825
+ try:
826
+ data = json.loads(raw.decode("utf-8")) if len(raw) <= STORE_MAX else None
827
+ except ValueError:
828
+ data = None
829
+ if not (isinstance(data, dict) and all(
830
+ FP_RE.fullmatch(fp) and isinstance(v, dict) and isinstance(v.get("reason"), str)
831
+ and isinstance(v.get("at"), (int, float)) and not isinstance(v["at"], bool) for fp, v in data.items())):
832
+ raise Fail(f"{path}: not a dismissal store ({{fingerprint: {{at, reason}}}}); fix or remove it")
833
+ return {fp: {"at": v["at"], "reason": v["reason"]} for fp, v in data.items()}
834
+
835
+
836
+ def save_dismissed(key, data):
837
+ """Replace the store atomically: a temp file in the same directory, fsynced, then renamed over it."""
838
+ base = store_dir(create=True)
839
+ path = base / f"{key}.json"
840
+ try:
841
+ fd, tmp = tempfile.mkstemp(prefix=f".{key}.", suffix=".tmp", dir=base)
842
+ try:
843
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
844
+ json.dump(data, fh, indent=2, sort_keys=True, ensure_ascii=False)
845
+ fh.write("\n")
846
+ fh.flush()
847
+ os.fsync(fh.fileno())
848
+ os.replace(tmp, path)
849
+ except BaseException:
850
+ with contextlib.suppress(OSError):
851
+ os.unlink(tmp)
852
+ raise
853
+ except OSError as exc:
854
+ raise Fail(f"{path}: cannot write the dismissal store: {exc.strerror}")
855
+
856
+
857
+ @contextlib.contextmanager
858
+ def repo_lock(key, label):
859
+ """Hold the repository's `<key>.lock` (flock, next to the store); busy for LOCK_WAIT seconds raises Busy.
860
+
861
+ The lock is its own file, opened in place and never replaced, because the store is: a lock on the store
862
+ file would stay with the old inode after an atomic save.
863
+ """
864
+ path = store_dir(create=True) / f"{key}.lock"
865
+ try:
866
+ fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
867
+ except OSError as exc:
868
+ raise Fail(f"{path}: cannot open the repository lock ({exc.strerror}); it must not be a symlink")
869
+ try:
870
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
871
+ raise Fail(f"{path}: the repository lock must be a regular file")
872
+ deadline = time.monotonic() + LOCK_WAIT
873
+ while True:
874
+ try:
875
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
876
+ break
877
+ except OSError as exc:
878
+ if exc.errno not in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
879
+ raise Fail(f"{path}: cannot lock the repository: {exc.strerror}")
880
+ if time.monotonic() >= deadline:
881
+ raise Busy(f"busy: another filing or dismissal for {label} is in progress")
882
+ time.sleep(LOCK_POLL)
883
+ yield
884
+ finally:
885
+ os.close(fd) # closing the descriptor releases the flock
886
+
887
+
888
+ def dismiss(args):
889
+ """--dismiss / --undismiss: update the repository's store under its lock. No network: <run> is only read to
890
+ find the repository it resolves to."""
891
+ meta, _found, _notes = collect(Path(args.run), False, args.all_runs)
892
+ repo_path, warnings = run_repository(meta)
893
+ key = repo_key(repo_path)
894
+ with repo_lock(key, args.repo or github_slug(repo_path) or repo_path):
895
+ data = load_dismissed(key)
896
+ before = json.dumps(data, sort_keys=True)
897
+ now = int(time.time())
898
+ for fp in args.dismiss:
899
+ # dismissing again without a --reason keeps the earlier mark, with its reason and date
900
+ if fp not in data or args.reason is not None:
901
+ data[fp] = {"at": now, "reason": args.reason or ""}
902
+ for fp in args.undismiss:
903
+ data.pop(fp, None)
904
+ if json.dumps(data, sort_keys=True) != before:
905
+ save_dismissed(key, data)
906
+ return {"ok": True, "dismissed": args.dismiss, "undismissed": args.undismiss, "warnings": warnings}
907
+
908
+
909
+ # ---------------------------------------------------------------------------------------------- main
910
+
911
+ def split_fps(values):
912
+ """Repeatable, comma-separated fingerprints, deduplicated in order; None when one is not 12 hex characters."""
913
+ fps = [x.strip() for v in values for x in v.split(",")]
914
+ return None if any(not FP_RE.fullmatch(x) for x in fps) else list(dict.fromkeys(fps))
915
+
916
+
917
+ def main(argv=None):
918
+ ap = argparse.ArgumentParser(prog="forge issues", description=__doc__.split("\n\n")[0],
919
+ formatter_class=argparse.RawDescriptionHelpFormatter,
920
+ epilog="\n\n".join(__doc__.split("\n\n")[1:]))
921
+ ap.add_argument("run", help="plan dir, task dir, review dir, known_issues.md or qa.last "
922
+ "(with --all-runs: a repository, or a plan dir naming one)")
923
+ ap.add_argument("--include-plausible", action="store_true", help="also file PLAUSIBLE findings, as 'To verify'")
924
+ ap.add_argument("--min-severity", choices=SEVERITIES, help="file only findings at least this severe")
925
+ ap.add_argument("--group", choices=["finding", "task", "run"], default="finding",
926
+ help="one issue per finding (default), per task, or one checklist issue per run")
927
+ ap.add_argument("--repo", help="owner/repo (default: the run's repository, from its origin remote)")
928
+ ap.add_argument("--lang", choices=sorted(TEXT), default="en", help="language of the issue text (default en)")
929
+ ap.add_argument("--label", action="append", default=[], help="label to add if the repo has it; repeatable")
930
+ ap.add_argument("--apply", action="store_true", help="create the issues (default: show drafts only)")
931
+ ap.add_argument("--confirm-new", action="store_true", help="create even when an issue with the same title exists")
932
+ ap.add_argument("--max-issues", type=int, default=10,
933
+ help="create at most this many per call (default 10); filing a --only selection: pass its size")
934
+ ap.add_argument("--offline", action="store_true", help="never run `gh`: no filed state, local refs only")
935
+ ap.add_argument("--only", action="append", default=[], metavar="FP",
936
+ help="keep only this 12-hex fingerprint; repeatable or comma-separated")
937
+ ap.add_argument("--all-runs", action="store_true", help="collect every run in the repository's run registry")
938
+ ap.add_argument("--dismiss", action="append", default=[], metavar="FP",
939
+ help="mark this 12-hex fingerprint as needing no work, then exit; repeatable or comma-separated")
940
+ ap.add_argument("--undismiss", action="append", default=[], metavar="FP",
941
+ help="take the mark back, then exit; repeatable or comma-separated")
942
+ ap.add_argument("--reason", help=f"why --dismiss marks them (at most {REASON_MAX} characters)")
943
+ ap.add_argument("--include-dismissed", action="store_true", help="draft dismissed findings too")
944
+ ap.add_argument("--json", action="store_true", help="machine-readable output")
945
+ args = ap.parse_args(argv)
946
+ if args.offline and args.apply:
947
+ ap.error("--offline cannot be combined with --apply")
948
+ only = split_fps(args.only)
949
+ if only is None:
950
+ ap.error("--only takes 12-hex-character fingerprints, repeated or comma-separated")
951
+ args.only = only
952
+ args.dismiss, args.undismiss = split_fps(args.dismiss), split_fps(args.undismiss)
953
+ if args.dismiss is None or args.undismiss is None:
954
+ ap.error("--dismiss and --undismiss take 12-hex-character fingerprints, repeated or comma-separated")
955
+ dismissing = bool(args.dismiss or args.undismiss)
956
+ if dismissing and args.apply:
957
+ ap.error("--dismiss and --undismiss cannot be combined with --apply")
958
+ if set(args.dismiss) & set(args.undismiss):
959
+ ap.error("a fingerprint cannot be both --dismiss and --undismiss")
960
+ if args.reason is not None:
961
+ if not args.dismiss:
962
+ ap.error("--reason goes with --dismiss")
963
+ try:
964
+ args.reason.encode("utf-8")
965
+ except UnicodeEncodeError:
966
+ ap.error("--reason must be valid UTF-8")
967
+ if len(args.reason) > REASON_MAX:
968
+ ap.error(f"--reason is at most {REASON_MAX} characters")
969
+ try:
970
+ out = dismiss(args) if dismissing else file_findings(args)
971
+ except Fail as exc:
972
+ print(f"forge issues: {exc}", file=sys.stderr)
973
+ return 3 if isinstance(exc, Busy) else 2
974
+ if args.json:
975
+ print(json.dumps(out, indent=2, ensure_ascii=False))
976
+ elif dismissing:
977
+ for warning in out["warnings"]:
978
+ print(f"warning: {warning}")
979
+ for verb in ("dismissed", "undismissed"):
980
+ if out[verb]:
981
+ print(f"{verb} {len(out[verb])} finding(s): {', '.join(out[verb])}")
982
+ else:
983
+ print_human(out, args.apply)
984
+ return 0
985
+
986
+
987
+ def finding_entry(f, filed=None, dismissed=None):
988
+ return {"fp": f["fp"], "severity": f["severity"], "confidence": f["confidence"], "origin": f["origin"],
989
+ "plan": f["plan"], "plan_dir": f["plan_dir"], "task": f["task"], "file": f["file"],
990
+ "summary": (f["text"].splitlines() or [""])[0][:160], "text": f["text"],
991
+ "reviewer": f["reviewer"], "commit": f.get("commit") or "",
992
+ "review": f.get("review"),
993
+ "filed": {"url": filed["url"], "state": filed["state"]} if filed else None,
994
+ "dismissed": dismissed}
995
+
996
+
997
+ def file_findings(args):
998
+ meta, found, notes = collect(Path(args.run), args.include_plausible, args.all_runs)
999
+ repo_path, warnings = run_repository(meta)
1000
+ slug = args.repo or github_slug(repo_path)
1001
+ if not slug:
1002
+ raise Fail("; ".join(warnings + [f"{repo_path} has no GitHub origin remote; pass --repo owner/repo"]))
1003
+ if args.min_severity:
1004
+ cut = SEVERITIES.index(args.min_severity)
1005
+ found = [f for f in found if SEVERITIES.index(f["severity"]) <= cut]
1006
+ # Filing is list-then-create, so no other filing or dismissal for this repository may run in between: the
1007
+ # lock covers the lookup, the drafts (which ask `gh` whether commits are published) and every create.
1008
+ with repo_lock(repo_key(repo_path), slug) if args.apply else contextlib.nullcontext():
1009
+ return list_and_file(args, meta, found, notes, repo_path, slug, warnings)
1010
+
1011
+
1012
+ def list_and_file(args, meta, found, notes, repo_path, slug, warnings):
1013
+ offline = args.offline
1014
+ dismissed = load_dismissed(repo_key(repo_path))
1015
+ issues = [] if offline else existing_issues(slug)
1016
+ filed = {fp: issue for issue in issues for fp in FP_MARK_RE.findall(issue.get("body") or "")}
1017
+ only = set(args.only)
1018
+ seen, todo, already, entries = set(), [], [], []
1019
+ known = set()
1020
+ for f in found:
1021
+ f["fp"] = fingerprint(slug, f)
1022
+ known.add(f["fp"])
1023
+ if f["fp"] in seen or (only and f["fp"] not in only):
1024
+ continue
1025
+ seen.add(f["fp"])
1026
+ issue = filed.get(f["fp"])
1027
+ mark = dismissed.get(f["fp"])
1028
+ entries.append(finding_entry(f, issue, mark))
1029
+ if issue:
1030
+ already.append({"finding": f["raw"], "issue": issue["url"], "state": issue["state"]})
1031
+ elif not mark or args.include_dismissed:
1032
+ todo.append(f)
1033
+ notes = notes + [f"--only {fp}: no such open finding" for fp in args.only if fp not in known]
1034
+ entries.sort(key=lambda e: SEVERITIES.index(e["severity"]))
1035
+ keyed = {}
1036
+ for f in todo:
1037
+ key = id(f) if args.group == "finding" else (f["plan"], f["origin"], f["task"] if args.group == "task" else "")
1038
+ keyed.setdefault(key, []).append(f)
1039
+ open_titles = {norm_title(i["title"]): i["url"] for i in issues if i.get("state") == "OPEN"}
1040
+ t = TEXT[args.lang]
1041
+ drafts = []
1042
+ for items in keyed.values():
1043
+ items.sort(key=lambda f: SEVERITIES.index(f["severity"]))
1044
+ if len(items) == 1:
1045
+ title = title_for(items[0], args.lang)
1046
+ else:
1047
+ scope = (items[0]["task"] or items[0]["plan"]) if args.group == "task" else items[0]["plan"]
1048
+ origin = items[0]["origin"]
1049
+ if origin == "accepted":
1050
+ title = f"[{items[0]['severity']}] {t['group_title_accepted'].format(scope=scope)}"
1051
+ else:
1052
+ prefix = t["plausible"] if origin == "plausible" else ""
1053
+ title = f"[{items[0]['severity']}] {prefix}{t['group_title'].format(scope=scope)}"
1054
+ drafts.append({"title": title, "severity": items[0]["severity"], "origin": items[0]["origin"],
1055
+ "findings": [f["raw"] for f in items], "fingerprints": [f["fp"] for f in items],
1056
+ "same_title": None if offline else open_titles.get(norm_title(title)),
1057
+ "body": body_for(slug, repo_path if meta["repo_path"] else None, items, args.lang, offline)})
1058
+ drafts.sort(key=lambda d: SEVERITIES.index(d["severity"]))
1059
+ out = {"repo": slug, "plan": meta["plan"], "notes": notes, "warnings": warnings,
1060
+ "summary": {"findings": len(seen), "already_filed": len(already),
1061
+ "dismissed": sum(1 for e in entries if e["dismissed"]), "drafts": len(drafts),
1062
+ **{o: sum(1 for f in todo if f["origin"] == o)
1063
+ for o in ("tolerated", "accepted", "review", "plausible")}},
1064
+ "findings": entries, "already_filed": already, "drafts": drafts}
1065
+ if args.apply:
1066
+ out["created"] = create(slug, drafts, args)
1067
+ for d in drafts:
1068
+ d.pop("body")
1069
+ return out
1070
+
1071
+
1072
+ def create(slug, drafts, args):
1073
+ labels = []
1074
+ if args.label:
1075
+ res = run(["gh", "label", "list", "--repo", slug, "--limit", "500", "--json", "name"], check=False)
1076
+ have = {x["name"] for x in json.loads(res.stdout or "[]")} if res.returncode == 0 else set()
1077
+ labels = [x for x in args.label if x in have]
1078
+ created = []
1079
+ for i, d in enumerate(drafts):
1080
+ if i >= args.max_issues:
1081
+ created.append({"title": d["title"], "skipped": f"over --max-issues {args.max_issues}; run again"})
1082
+ continue
1083
+ if d["same_title"] and not args.confirm_new:
1084
+ created.append({"title": d["title"], "skipped": "an open issue has the same title", "issue": d["same_title"]})
1085
+ continue
1086
+ with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as fh:
1087
+ fh.write(d["body"])
1088
+ try:
1089
+ cmd = ["gh", "issue", "create", "--repo", slug, "--title", d["title"], "--body-file", fh.name]
1090
+ for label in labels:
1091
+ cmd += ["--label", label]
1092
+ res = run(cmd)
1093
+ finally:
1094
+ os.unlink(fh.name)
1095
+ url = next((w for w in res.stdout.split() if w.startswith("https://")), res.stdout.strip())
1096
+ created.append({"title": d["title"], "url": url})
1097
+ missing = sorted(set(args.label) - set(labels))
1098
+ if missing:
1099
+ created.append({"note": f"labels not in {slug}, left off: {', '.join(missing)}"})
1100
+ return created
1101
+
1102
+
1103
+ def print_human(out, applied):
1104
+ s = out["summary"]
1105
+ dismissed = f", {s['dismissed']} dismissed" if s["dismissed"] else ""
1106
+ print(f"{out['repo']} · run {out['plan']}: {s['findings']} finding(s), {s['already_filed']} already filed{dismissed}, "
1107
+ f"{s['drafts']} draft(s) ({s['tolerated']} tolerated, {s['accepted']} accepted, "
1108
+ f"{s['review']} review, {s['plausible']} plausible)")
1109
+ for warning in out["warnings"]:
1110
+ print(f" warning {warning}")
1111
+ for note in out["notes"]:
1112
+ print(f" skipped {note}")
1113
+ for a in out["already_filed"]:
1114
+ print(f" filed {a['issue']} ({a['state'].lower()}): {a['finding'][:100]}")
1115
+ if applied:
1116
+ for c in out["created"]:
1117
+ if "note" in c:
1118
+ print(f" note {c['note']}")
1119
+ elif "url" in c:
1120
+ print(f" created {c['url']} {c['title']}")
1121
+ else:
1122
+ print(f" skipped {c['title']}: {c['skipped']}{' ' + c['issue'] if c.get('issue') else ''}")
1123
+ return
1124
+ outdated = {e["fp"] for e in out["findings"] if (e.get("review") or {}).get("outdated") is True}
1125
+ for d in out["drafts"]:
1126
+ flag = f" (open issue with the same title: {d['same_title']})" if d["same_title"] else ""
1127
+ mark = " (outdated review)" if any(fp in outdated for fp in d["fingerprints"]) else ""
1128
+ print(f" draft {d['title']}{mark}{flag}")
1129
+ if out["drafts"]:
1130
+ print("Nothing created: add --apply to file these.")
1131
+
1132
+
1133
+ if __name__ == "__main__":
1134
+ sys.exit(main())