@stylusnexus/work-plan 2026.6.16 → 2026.6.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +2 -0
- package/skills/work-plan/commands/auto_triage.py +187 -35
- package/skills/work-plan/commands/batch_slot.py +58 -33
- package/skills/work-plan/commands/slot.py +53 -24
- package/skills/work-plan/lib/heuristic_triage.py +134 -0
- package/skills/work-plan/lib/membership_guard.py +152 -0
- package/skills/work-plan/lib/plan_worktree.py +37 -0
- package/skills/work-plan/tests/test_auto_triage.py +103 -0
- package/skills/work-plan/tests/test_batch_slot.py +19 -2
- package/skills/work-plan/tests/test_heuristic_triage.py +114 -0
- package/skills/work-plan/tests/test_membership_guard.py +116 -0
- package/skills/work-plan/tests/test_shared_rebase_guard.py +154 -0
- package/skills/work-plan/tests/test_slot.py +94 -2
- package/skills/work-plan/tests/test_slot_move.py +10 -1
- package/skills/work-plan/work_plan.py +2 -2
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Deterministic, offline track suggestions for untracked issues (#373).
|
|
2
|
+
|
|
3
|
+
The LLM path (`auto-triage` → a Claude session writes the answers) is the
|
|
4
|
+
higher-quality source, but it only produces suggestions when an agent is driving.
|
|
5
|
+
This module is the no-LLM fallback: it scores each untracked issue against each
|
|
6
|
+
candidate track using only signals already in hand (no network, no model), so the
|
|
7
|
+
VS Code Suggested bucket works standalone.
|
|
8
|
+
|
|
9
|
+
It emits the SAME v2 answer entries the LLM path does
|
|
10
|
+
(`{issue, verdict, track, runner_up, confidence, margin, rationale}`) so the
|
|
11
|
+
viewer and `auto-triage --apply` consume it unchanged — abstain-first, with a
|
|
12
|
+
grounded rationale naming the concrete signal that matched.
|
|
13
|
+
|
|
14
|
+
Signals (all local, all explainable):
|
|
15
|
+
* milestone match — issue.milestone == track.milestone_alignment (strong)
|
|
16
|
+
* track-label match — issue labels ∩ the track's reconcile labels
|
|
17
|
+
(github.labels, else the default `track/<slug>`) (strong)
|
|
18
|
+
* keyword overlap — issue-title tokens ∩ the track's slug/name/scope tokens
|
|
19
|
+
(medium; capped)
|
|
20
|
+
|
|
21
|
+
Confidence here is a heuristic score, NOT a calibrated probability — the answer
|
|
22
|
+
doc is stamped `source: "heuristic"` so the viewer can flag it as lower-trust.
|
|
23
|
+
"""
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
# Weights are deliberately simple and sum-clamped to 1.0. Tuned so a single
|
|
27
|
+
# strong signal alone stays below the default suggest bar (0.3 < 0.4/0.5), i.e.
|
|
28
|
+
# one weak coincidence won't auto-suggest, but a strong signal does clear it.
|
|
29
|
+
_W_MILESTONE = 0.5
|
|
30
|
+
_W_LABEL = 0.4
|
|
31
|
+
_W_KEYWORD_EACH = 0.1
|
|
32
|
+
_W_KEYWORD_CAP = 0.3
|
|
33
|
+
|
|
34
|
+
# Short / structural words that carry no track-matching signal.
|
|
35
|
+
_STOPWORDS = frozenset((
|
|
36
|
+
"the", "a", "an", "and", "or", "to", "of", "for", "in", "on", "with", "is",
|
|
37
|
+
"add", "fix", "update", "support", "make", "use", "new", "issue", "bug",
|
|
38
|
+
"feat", "feature", "error", "when", "after", "before", "via", "from", "into",
|
|
39
|
+
))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _tokens(text):
|
|
43
|
+
"""Lowercase alphanumeric tokens of length ≥ 3, minus stopwords."""
|
|
44
|
+
if not text:
|
|
45
|
+
return set()
|
|
46
|
+
return {
|
|
47
|
+
t for t in re.split(r"[^a-z0-9]+", str(text).lower())
|
|
48
|
+
if len(t) >= 3 and t not in _STOPWORDS
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _track_labels(track):
|
|
53
|
+
"""A track's effective reconcile labels (lowercased): github.labels if set,
|
|
54
|
+
else the default `track/<slug>` — mirrors reconcile's resolution (#373)."""
|
|
55
|
+
labels = (track.get("labels") or [])
|
|
56
|
+
if labels:
|
|
57
|
+
return {str(x).lower() for x in labels}
|
|
58
|
+
slug = track.get("slug") or ""
|
|
59
|
+
return {f"track/{slug}".lower()} if slug else set()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def score_suggestions(untracked, tracks, *, min_score=0.3, margin_gap=0.15):
|
|
63
|
+
"""Score each untracked issue against the candidate tracks and return v2
|
|
64
|
+
suggestion entries (abstain-first).
|
|
65
|
+
|
|
66
|
+
`untracked`: [{"number", "title", "milestone": {"title"} | None,
|
|
67
|
+
"labels": [{"name"}]}] (the auto-triage batch shape).
|
|
68
|
+
`tracks`: [{"slug", "name", "milestone", "scope", "labels": [..]}].
|
|
69
|
+
`min_score`: a track must clear this for a non-abstain suggestion.
|
|
70
|
+
`margin_gap`: top must beat the runner-up by at least this for margin "clear".
|
|
71
|
+
"""
|
|
72
|
+
out = []
|
|
73
|
+
for iss in untracked:
|
|
74
|
+
try:
|
|
75
|
+
num = int(iss.get("number"))
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
title_tokens = _tokens(iss.get("title"))
|
|
80
|
+
ms = iss.get("milestone") or {}
|
|
81
|
+
iss_ms = ms.get("title") if isinstance(ms, dict) else None
|
|
82
|
+
iss_labels = {str(lb.get("name", "")).lower()
|
|
83
|
+
for lb in (iss.get("labels") or []) if isinstance(lb, dict)}
|
|
84
|
+
|
|
85
|
+
scored = [] # (score, slug, rationale-parts)
|
|
86
|
+
for t in tracks:
|
|
87
|
+
slug = t.get("slug")
|
|
88
|
+
if not slug:
|
|
89
|
+
continue
|
|
90
|
+
score = 0.0
|
|
91
|
+
reasons = []
|
|
92
|
+
|
|
93
|
+
t_ms = t.get("milestone")
|
|
94
|
+
if iss_ms and t_ms and iss_ms == t_ms:
|
|
95
|
+
score += _W_MILESTONE
|
|
96
|
+
reasons.append(f"milestone {iss_ms}")
|
|
97
|
+
|
|
98
|
+
shared_labels = iss_labels & _track_labels(t)
|
|
99
|
+
if shared_labels:
|
|
100
|
+
score += _W_LABEL
|
|
101
|
+
reasons.append("label " + ", ".join(sorted(shared_labels)))
|
|
102
|
+
|
|
103
|
+
t_kw = _tokens(slug) | _tokens(t.get("name")) | _tokens(t.get("scope"))
|
|
104
|
+
shared_kw = title_tokens & t_kw
|
|
105
|
+
if shared_kw:
|
|
106
|
+
score += min(_W_KEYWORD_CAP, _W_KEYWORD_EACH * len(shared_kw))
|
|
107
|
+
reasons.append("keyword " + ", ".join(sorted(shared_kw)))
|
|
108
|
+
|
|
109
|
+
if score > 0:
|
|
110
|
+
scored.append((round(min(score, 1.0), 2), slug, reasons))
|
|
111
|
+
|
|
112
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
113
|
+
|
|
114
|
+
if not scored or scored[0][0] < min_score:
|
|
115
|
+
out.append({
|
|
116
|
+
"issue": num,
|
|
117
|
+
"verdict": "abstain",
|
|
118
|
+
"rationale": "no track clears the heuristic bar",
|
|
119
|
+
})
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
top = scored[0]
|
|
123
|
+
runner = scored[1] if len(scored) > 1 else None
|
|
124
|
+
clear = runner is None or (top[0] - runner[0]) >= margin_gap
|
|
125
|
+
out.append({
|
|
126
|
+
"issue": num,
|
|
127
|
+
"verdict": "suggest",
|
|
128
|
+
"track": top[1],
|
|
129
|
+
**({"runner_up": runner[1]} if runner else {}),
|
|
130
|
+
"confidence": top[0],
|
|
131
|
+
"margin": "clear" if clear else "narrow",
|
|
132
|
+
"rationale": "; ".join(top[2]),
|
|
133
|
+
})
|
|
134
|
+
return out
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Compare-and-swap guard for track-membership writes (#241).
|
|
2
|
+
|
|
3
|
+
Slotting an issue into a track edits `meta["github"]["issues"]`. Shared-tier
|
|
4
|
+
tracks travel via git push/pull, so an assisted or background write can race
|
|
5
|
+
another session or a teammate's pull. `lib.frontmatter.write_file` is a blind
|
|
6
|
+
overwrite, so without a guard the last writer silently wins.
|
|
7
|
+
|
|
8
|
+
This module adds two things:
|
|
9
|
+
|
|
10
|
+
* `issues_fingerprint(meta)` — a deterministic digest of the membership list
|
|
11
|
+
ONLY. We fingerprint just `github.issues`, not the whole frontmatter or the
|
|
12
|
+
body: those carry fields other commands legitimately rewrite concurrently
|
|
13
|
+
(`refresh-md` stamps `last_touched`, `handoff` rewrites the body table), and
|
|
14
|
+
fingerprinting them would abort on changes that have nothing to do with the
|
|
15
|
+
list we're about to overwrite. The membership list is exactly the CAS surface.
|
|
16
|
+
|
|
17
|
+
* `guarded_membership_write(...)` — ALWAYS re-reads the file from disk
|
|
18
|
+
immediately before writing, applies the membership delta to the FRESH
|
|
19
|
+
frontmatter, and writes back the rest of the frontmatter and the body
|
|
20
|
+
unchanged. So a concurrent edit to the body OR to other frontmatter fields
|
|
21
|
+
(status, last_touched, depends_on, …) is preserved — only the issues list is
|
|
22
|
+
replaced. When `expect` is supplied and the on-disk membership no longer
|
|
23
|
+
matches it, the write is ABORTED and a `{"stale": ...}` signal is returned
|
|
24
|
+
instead of overwriting — the caller re-prompts on fresh state. When `expect`
|
|
25
|
+
is None (the manual single-writer path) the abort is skipped, but the
|
|
26
|
+
re-read + merge still happens: strictly safer than a blind overwrite, with
|
|
27
|
+
the same observable result for a lone writer.
|
|
28
|
+
|
|
29
|
+
Scope of the guarantee (deliberately narrow — don't oversell it):
|
|
30
|
+
- This is a check-then-act, not a locked atomic CAS: `parse_file` then
|
|
31
|
+
`write_file` are separate syscalls with no file lock, so a writer landing in
|
|
32
|
+
the sub-millisecond window between them isn't caught. `expect` narrows the
|
|
33
|
+
window vs. a blind overwrite; it does not eliminate it. Adequate for the real
|
|
34
|
+
usage (interactive single user + occasional same-machine background).
|
|
35
|
+
- `shared_rebase_guard` lands a shared-tier write on top of origin AS OF THE
|
|
36
|
+
LAST FETCH; it is NOT a cross-machine atomic CAS against origin. A teammate
|
|
37
|
+
pushing between the rebase and the eventual (separate) push is reconciled by
|
|
38
|
+
the non-fast-forward push being rejected + rebase-on-next-write, not by this
|
|
39
|
+
write path.
|
|
40
|
+
- `frontmatter.write_file` re-serializes the frontmatter via yq on every
|
|
41
|
+
write, so body-only edits round-trip verbatim but YAML comments / key order
|
|
42
|
+
are normalized (a pre-existing property, not introduced here).
|
|
43
|
+
"""
|
|
44
|
+
import hashlib
|
|
45
|
+
import json
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
from lib.frontmatter import parse_file, write_file
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _issue_set(meta: dict) -> set:
|
|
52
|
+
"""The frontmatter's github.issues as a set of ints (malformed entries
|
|
53
|
+
dropped — the file may be hand-edited)."""
|
|
54
|
+
out = set()
|
|
55
|
+
for n in (meta.get("github", {}).get("issues") or []):
|
|
56
|
+
try:
|
|
57
|
+
out.add(int(n))
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
continue
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def issues_fingerprint(meta: dict) -> str:
|
|
64
|
+
"""Deterministic sha256[:16] of the sorted github.issues list.
|
|
65
|
+
|
|
66
|
+
Order-independent (sorted) and stable across runs (no randomness — 3.9
|
|
67
|
+
stdlib). Two metas with the same membership produce the same fingerprint
|
|
68
|
+
regardless of list order or unrelated frontmatter differences.
|
|
69
|
+
"""
|
|
70
|
+
payload = json.dumps(sorted(_issue_set(meta)), separators=(",", ":"))
|
|
71
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def guarded_membership_write(path, *, add_nums=(), remove_nums=(), expect=None):
|
|
75
|
+
"""Re-read `path`, apply the membership delta to the fresh frontmatter, and
|
|
76
|
+
write back the fresh body unchanged.
|
|
77
|
+
|
|
78
|
+
Returns one of:
|
|
79
|
+
{"stale": True, "reason": str, "current": [int]}
|
|
80
|
+
— `expect` was supplied and the on-disk membership no longer matches
|
|
81
|
+
it; NO write happened. `current` is the fresh on-disk list so the
|
|
82
|
+
caller can re-offer against it.
|
|
83
|
+
{"written": [int]}
|
|
84
|
+
— wrote successfully; the value is the final sorted membership list.
|
|
85
|
+
|
|
86
|
+
`expect` is the `issues_fingerprint` the caller captured when it built the
|
|
87
|
+
operation (e.g. when the viewer rendered the offer). Pass None for the
|
|
88
|
+
unguarded manual path.
|
|
89
|
+
"""
|
|
90
|
+
fresh_meta, fresh_body = parse_file(Path(path))
|
|
91
|
+
|
|
92
|
+
if expect is not None and issues_fingerprint(fresh_meta) != expect:
|
|
93
|
+
return {
|
|
94
|
+
"stale": True,
|
|
95
|
+
"reason": "track membership changed since the operation was prepared",
|
|
96
|
+
"current": sorted(_issue_set(fresh_meta)),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
issues = _issue_set(fresh_meta)
|
|
100
|
+
issues |= {int(n) for n in add_nums}
|
|
101
|
+
issues -= {int(n) for n in remove_nums}
|
|
102
|
+
final = sorted(issues)
|
|
103
|
+
fresh_meta.setdefault("github", {})["issues"] = final
|
|
104
|
+
write_file(Path(path), fresh_meta, fresh_body)
|
|
105
|
+
return {"written": final}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def shared_rebase_guard(target, cfg):
|
|
109
|
+
"""Before writing a SHARED-tier track pinned to a `plan_branch`, fetch +
|
|
110
|
+
rebase its worktree onto origin so the write lands on top of a teammate's
|
|
111
|
+
pushed plan changes (#241). Best-effort: this reduces the cross-machine
|
|
112
|
+
(git push/pull) race, it does not make the write atomic against origin — a
|
|
113
|
+
teammate pushing after the rebase is reconciled by the non-fast-forward push
|
|
114
|
+
rejection + rebase-on-next-write, not here. The fingerprint CAS covers the
|
|
115
|
+
same-machine race.
|
|
116
|
+
|
|
117
|
+
Returns (ok: bool, reason: str | None):
|
|
118
|
+
(True, None) — safe to proceed: the track is private, a legacy
|
|
119
|
+
shared track (no plan_branch → working-tree tier), or
|
|
120
|
+
the worktree rebased cleanly / had no upstream.
|
|
121
|
+
(False, reason) — the shared branch diverged and could NOT auto-rebase;
|
|
122
|
+
the caller MUST abort and surface {needs_rebase}
|
|
123
|
+
rather than blind-write over a diverged branch.
|
|
124
|
+
|
|
125
|
+
Never raises — an unexpected guard error degrades to "proceed" (consistent
|
|
126
|
+
with the toolkit's never-break-the-command VCS philosophy; the underlying
|
|
127
|
+
plan_worktree ops are themselves never-raise).
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
if getattr(target, "tier", None) != "shared":
|
|
131
|
+
return (True, None)
|
|
132
|
+
repos = (cfg or {}).get("repos", {}) or {}
|
|
133
|
+
entry = repos.get(getattr(target, "folder", None))
|
|
134
|
+
if entry is None:
|
|
135
|
+
for e in repos.values():
|
|
136
|
+
if e and e.get("github") == getattr(target, "repo", None):
|
|
137
|
+
entry = e
|
|
138
|
+
break
|
|
139
|
+
branch = entry.get("plan_branch") if entry else None
|
|
140
|
+
local = entry.get("local") if entry else None
|
|
141
|
+
if not branch or not local:
|
|
142
|
+
return (True, None) # legacy shared tier (working tree) — no rebase
|
|
143
|
+
from lib import plan_worktree
|
|
144
|
+
worktree = plan_worktree.ensure_worktree(Path(local).expanduser(), branch)
|
|
145
|
+
if worktree is None:
|
|
146
|
+
return (True, None) # can't ensure the worktree — degrade, proceed
|
|
147
|
+
if not plan_worktree.rebase_onto_origin(worktree, branch):
|
|
148
|
+
return (False, f"shared plan branch '{branch}' diverged and could not "
|
|
149
|
+
f"auto-rebase; resolve manually")
|
|
150
|
+
return (True, None)
|
|
151
|
+
except Exception:
|
|
152
|
+
return (True, None)
|
|
@@ -267,6 +267,43 @@ def is_published(local_path: Path, branch: str) -> bool:
|
|
|
267
267
|
return remote_branch_exists(local_path, branch)
|
|
268
268
|
|
|
269
269
|
|
|
270
|
+
def rebase_onto_origin(worktree: Path, branch: str) -> bool:
|
|
271
|
+
"""Fetch origin/<branch> and rebase the worktree (checked out at <branch>)
|
|
272
|
+
onto it, so a shared-tier write lands on top of any teammate's pushed plan
|
|
273
|
+
changes instead of diverging (#241).
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
True — the worktree is now at-or-ahead of origin: rebase succeeded,
|
|
277
|
+
there was nothing to replay, or the branch isn't published yet
|
|
278
|
+
(local is authoritative — nothing to rebase onto).
|
|
279
|
+
False — the rebase hit a conflict (it is ABORTED so the worktree is left
|
|
280
|
+
clean, never half-rebased) or git couldn't run. The caller must
|
|
281
|
+
NOT write — it surfaces {needs_rebase} and bails.
|
|
282
|
+
|
|
283
|
+
Never raises.
|
|
284
|
+
"""
|
|
285
|
+
wt = Path(worktree).expanduser()
|
|
286
|
+
# Fetch so origin/<branch> is authoritative before we compare.
|
|
287
|
+
fetch_branch(wt, branch)
|
|
288
|
+
if not remote_branch_exists(wt, branch):
|
|
289
|
+
return True # unpublished — no upstream to rebase onto; local wins
|
|
290
|
+
# --autostash: the normal flow is write-file-then-commit, so the worktree's
|
|
291
|
+
# .work-plan/ is routinely dirty when we rebase. Without autostash git would
|
|
292
|
+
# refuse the rebase on the dirty precondition and we'd report a spurious
|
|
293
|
+
# {needs_rebase}; autostash shelves the local edits, rebases, and reapplies
|
|
294
|
+
# them on top — so a clean rebase succeeds even with pending plan edits.
|
|
295
|
+
proc = _git(wt, "rebase", "--autostash", f"origin/{branch}")
|
|
296
|
+
if proc is None:
|
|
297
|
+
return False
|
|
298
|
+
if proc.returncode == 0:
|
|
299
|
+
return True
|
|
300
|
+
# True conflict (autostash reapply or replayed commits): abort so the
|
|
301
|
+
# worktree is never left in a partially-rebased state. A blind write over a
|
|
302
|
+
# diverged shared branch is exactly what this guard exists to prevent.
|
|
303
|
+
_git(wt, "rebase", "--abort")
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
|
|
270
307
|
def unpushed_oneline(local_path: Path, branch: str) -> list:
|
|
271
308
|
"""One-line summaries of commits on local `branch` not yet on origin
|
|
272
309
|
(`origin/<branch>..<branch>`). If origin/<branch> doesn't exist, every commit
|
|
@@ -347,5 +347,108 @@ class AutoTriageApplyTest(unittest.TestCase):
|
|
|
347
347
|
self.assertIn("0 issue(s) assigned", buf.getvalue())
|
|
348
348
|
|
|
349
349
|
|
|
350
|
+
class AutoTriageJsonScanTest(unittest.TestCase):
|
|
351
|
+
"""--json scan mode (#241): emit a machine batch with a batch_id."""
|
|
352
|
+
|
|
353
|
+
def test_json_mode_emits_batch_with_id_and_prompt(self):
|
|
354
|
+
cfg = _make_cfg()
|
|
355
|
+
tracks = [_make_track("auth-flow", "org/myrepo", [])]
|
|
356
|
+
rc, out, _ = _drive_prepare(["--json"], cfg=cfg, tracks=tracks,
|
|
357
|
+
open_issues=_open_issues(4501, 4502))
|
|
358
|
+
self.assertEqual(rc, 0)
|
|
359
|
+
data = json.loads(out.strip()) # stdout is a single JSON object
|
|
360
|
+
self.assertIn("batch_id", data)
|
|
361
|
+
self.assertTrue(data["batch_id"])
|
|
362
|
+
self.assertEqual({i["number"] for i in data["untracked"]}, {4501, 4502})
|
|
363
|
+
self.assertIn("prompt", data)
|
|
364
|
+
self.assertIn("answers_path", data)
|
|
365
|
+
# Track entries carry scope text for grounded matching.
|
|
366
|
+
self.assertIn("scope", data["tracks"][0])
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class AutoTriageHeuristicTest(unittest.TestCase):
|
|
370
|
+
"""--heuristic (#373): the CLI writes the v2 answers file itself, no LLM."""
|
|
371
|
+
|
|
372
|
+
def test_heuristic_writes_answers_file_with_source_and_batch_id(self):
|
|
373
|
+
cfg = _make_cfg()
|
|
374
|
+
track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
|
|
375
|
+
track.meta["milestone_alignment"] = "v0.4"
|
|
376
|
+
open_issues = [{"number": 4501, "title": "auth thing", "state": "OPEN",
|
|
377
|
+
"milestone": {"title": "v0.4"}, "labels": []}]
|
|
378
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
379
|
+
batch_file = Path(tmp) / "auto_triage.org_myrepo.json"
|
|
380
|
+
answers_file = Path(tmp) / "auto_triage.org_myrepo.answers.json"
|
|
381
|
+
with patch("commands.auto_triage.load_config", return_value=cfg), \
|
|
382
|
+
patch("commands.auto_triage.discover_tracks", return_value=[track]), \
|
|
383
|
+
patch("commands.auto_triage.fetch_open_issues", return_value=open_issues), \
|
|
384
|
+
patch("commands.auto_triage._batch_path", return_value=batch_file), \
|
|
385
|
+
patch("commands.auto_triage._answers_path", return_value=answers_file):
|
|
386
|
+
buf = io.StringIO()
|
|
387
|
+
with redirect_stdout(buf):
|
|
388
|
+
rc = auto_triage.run(["--heuristic"])
|
|
389
|
+
self.assertEqual(rc, 0)
|
|
390
|
+
self.assertTrue(answers_file.exists(), "heuristic must write the answers file")
|
|
391
|
+
doc = json.loads(answers_file.read_text())
|
|
392
|
+
self.assertEqual(doc["version"], 2)
|
|
393
|
+
self.assertEqual(doc["source"], "heuristic")
|
|
394
|
+
batch = json.loads(batch_file.read_text())
|
|
395
|
+
self.assertEqual(doc["batch_id"], batch["batch_id"]) # correlates
|
|
396
|
+
sug = doc["suggestions"][0]
|
|
397
|
+
self.assertEqual(sug["issue"], 4501)
|
|
398
|
+
self.assertEqual(sug["verdict"], "suggest")
|
|
399
|
+
self.assertEqual(sug["track"], "auth-flow")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class AutoTriageV2AnswersTest(unittest.TestCase):
|
|
403
|
+
"""v2 abstain-first answers schema (#241) + back-compat with v1."""
|
|
404
|
+
|
|
405
|
+
def _batch(self, nums, batch_id="abc123"):
|
|
406
|
+
return {"batch_id": batch_id, "repo": "org/myrepo", "folder": "myrepo",
|
|
407
|
+
"untracked": [{"number": n} for n in nums]}
|
|
408
|
+
|
|
409
|
+
def test_v2_applies_only_clear_suggestions(self):
|
|
410
|
+
cfg = _make_cfg()
|
|
411
|
+
track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
|
|
412
|
+
answers = {"version": 2, "batch_id": "abc123", "suggestions": [
|
|
413
|
+
{"issue": 4501, "verdict": "suggest", "track": "auth-flow",
|
|
414
|
+
"margin": "clear", "confidence": 0.9, "rationale": "label area/auth"},
|
|
415
|
+
{"issue": 4502, "verdict": "suggest", "track": "auth-flow",
|
|
416
|
+
"margin": "narrow", "confidence": 0.55, "rationale": "maybe"},
|
|
417
|
+
{"issue": 4507, "verdict": "abstain", "rationale": "no fit"},
|
|
418
|
+
]}
|
|
419
|
+
rc, mwrite, out = _drive_apply(cfg=cfg, tracks=[track],
|
|
420
|
+
batch=self._batch([4501, 4502, 4507]),
|
|
421
|
+
answers=answers)
|
|
422
|
+
self.assertEqual(rc, 0)
|
|
423
|
+
mwrite.assert_called_once() # only the clear suggestion is written
|
|
424
|
+
written = mwrite.call_args[0][1]["github"]["issues"]
|
|
425
|
+
self.assertIn(4501, written)
|
|
426
|
+
self.assertNotIn(4502, written) # narrow margin → left untracked
|
|
427
|
+
self.assertNotIn(4507, written) # abstained → left untracked
|
|
428
|
+
|
|
429
|
+
def test_v1_answers_still_apply(self):
|
|
430
|
+
cfg = _make_cfg()
|
|
431
|
+
track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
|
|
432
|
+
answers = [{"track": "auth-flow", "issues": [4501]}] # legacy shape
|
|
433
|
+
rc, mwrite, out = _drive_apply(cfg=cfg, tracks=[track],
|
|
434
|
+
batch=self._batch([4501]), answers=answers)
|
|
435
|
+
self.assertEqual(rc, 0)
|
|
436
|
+
mwrite.assert_called_once()
|
|
437
|
+
self.assertIn(4501, mwrite.call_args[0][1]["github"]["issues"])
|
|
438
|
+
|
|
439
|
+
def test_batch_id_mismatch_warns_but_applies(self):
|
|
440
|
+
cfg = _make_cfg()
|
|
441
|
+
track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
|
|
442
|
+
answers = {"version": 2, "batch_id": "STALE", "suggestions": [
|
|
443
|
+
{"issue": 4501, "verdict": "suggest", "track": "auth-flow",
|
|
444
|
+
"margin": "clear", "rationale": "label area/auth"}]}
|
|
445
|
+
rc, mwrite, out = _drive_apply(cfg=cfg, tracks=[track],
|
|
446
|
+
batch=self._batch([4501], batch_id="abc123"),
|
|
447
|
+
answers=answers)
|
|
448
|
+
self.assertEqual(rc, 0)
|
|
449
|
+
self.assertIn("older scan", out)
|
|
450
|
+
mwrite.assert_called_once()
|
|
451
|
+
|
|
452
|
+
|
|
350
453
|
if __name__ == "__main__":
|
|
351
454
|
unittest.main()
|
|
@@ -52,11 +52,21 @@ def _drive(args, tracks=None, vis="PRIVATE"):
|
|
|
52
52
|
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
|
|
53
53
|
gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
|
|
54
54
|
|
|
55
|
+
# Writes go through lib.membership_guard (re-read via parse_file, write via
|
|
56
|
+
# write_file). Returning each track's own meta/body lets the guard mutate
|
|
57
|
+
# them in place, so assertions on track.meta still observe the merge.
|
|
58
|
+
by_path = {str(t.path): t for t in tracks}
|
|
59
|
+
|
|
60
|
+
def fake_parse(p):
|
|
61
|
+
t = by_path[str(p)]
|
|
62
|
+
return (t.meta, t.body)
|
|
63
|
+
|
|
55
64
|
with patch("commands.batch_slot.load_config", return_value=cfg), \
|
|
56
65
|
patch("commands.batch_slot.discover_tracks", return_value=tracks), \
|
|
57
66
|
patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \
|
|
58
67
|
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
59
|
-
patch("
|
|
68
|
+
patch("lib.membership_guard.parse_file", side_effect=fake_parse), \
|
|
69
|
+
patch("lib.membership_guard.write_file") as mw:
|
|
60
70
|
buf = io.StringIO()
|
|
61
71
|
with redirect_stdout(buf):
|
|
62
72
|
rc = batch_slot.run(args)
|
|
@@ -272,6 +282,12 @@ class BatchSlotTest(unittest.TestCase):
|
|
|
272
282
|
def _raise(*a, **kw):
|
|
273
283
|
raise AssertionError("input() must not be called on non-interactive path")
|
|
274
284
|
|
|
285
|
+
by_path = {str(t.path): t for t in (source, target)}
|
|
286
|
+
|
|
287
|
+
def fake_parse(p):
|
|
288
|
+
t = by_path[str(p)]
|
|
289
|
+
return (t.meta, t.body)
|
|
290
|
+
|
|
275
291
|
with patch("builtins.input", side_effect=_raise), \
|
|
276
292
|
patch("lib.prompts.prompt_input", side_effect=_raise):
|
|
277
293
|
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
|
|
@@ -280,7 +296,8 @@ class BatchSlotTest(unittest.TestCase):
|
|
|
280
296
|
patch("commands.batch_slot.discover_tracks", return_value=[source, target]), \
|
|
281
297
|
patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \
|
|
282
298
|
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
283
|
-
patch("
|
|
299
|
+
patch("lib.membership_guard.parse_file", side_effect=fake_parse), \
|
|
300
|
+
patch("lib.membership_guard.write_file"):
|
|
284
301
|
buf = io.StringIO()
|
|
285
302
|
with redirect_stdout(buf):
|
|
286
303
|
rc = batch_slot.run(["42", "beta", "--move"])
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Tests for lib.heuristic_triage — the offline (no-LLM) track scorer (#373).
|
|
2
|
+
|
|
3
|
+
Covers: milestone match, track-label overlap (incl. the track/<slug> default),
|
|
4
|
+
keyword overlap, abstain when nothing clears the bar, margin clear-vs-narrow from
|
|
5
|
+
the top-vs-runner gap, and the v2 entry shape.
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
13
|
+
|
|
14
|
+
from lib.heuristic_triage import score_suggestions
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _iss(number, title="", milestone=None, labels=()):
|
|
18
|
+
return {
|
|
19
|
+
"number": number,
|
|
20
|
+
"title": title,
|
|
21
|
+
"milestone": {"title": milestone} if milestone else None,
|
|
22
|
+
"labels": [{"name": n} for n in labels],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _trk(slug, name=None, milestone=None, scope="", labels=None):
|
|
27
|
+
return {"slug": slug, "name": name or slug, "milestone": milestone,
|
|
28
|
+
"scope": scope, "labels": labels}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HeuristicScoreTest(unittest.TestCase):
|
|
32
|
+
|
|
33
|
+
def _one(self, entries):
|
|
34
|
+
self.assertEqual(len(entries), 1)
|
|
35
|
+
return entries[0]
|
|
36
|
+
|
|
37
|
+
def test_milestone_match_suggests(self):
|
|
38
|
+
e = self._one(score_suggestions(
|
|
39
|
+
[_iss(1, "rate limit", milestone="v0.4")],
|
|
40
|
+
[_trk("auth-flow", milestone="v0.4")],
|
|
41
|
+
))
|
|
42
|
+
self.assertEqual(e["verdict"], "suggest")
|
|
43
|
+
self.assertEqual(e["track"], "auth-flow")
|
|
44
|
+
self.assertIn("milestone v0.4", e["rationale"])
|
|
45
|
+
|
|
46
|
+
def test_track_label_overlap_suggests(self):
|
|
47
|
+
e = self._one(score_suggestions(
|
|
48
|
+
[_iss(2, "something", labels=["area/auth"])],
|
|
49
|
+
[_trk("auth-flow", labels=["area/auth"])],
|
|
50
|
+
))
|
|
51
|
+
self.assertEqual(e["verdict"], "suggest")
|
|
52
|
+
self.assertIn("label area/auth", e["rationale"])
|
|
53
|
+
|
|
54
|
+
def test_default_track_slug_label(self):
|
|
55
|
+
# No github.labels on the track → default `track/<slug>` is matched.
|
|
56
|
+
e = self._one(score_suggestions(
|
|
57
|
+
[_iss(3, "x", labels=["track/auth-flow"])],
|
|
58
|
+
[_trk("auth-flow", labels=None)],
|
|
59
|
+
))
|
|
60
|
+
self.assertEqual(e["verdict"], "suggest")
|
|
61
|
+
|
|
62
|
+
def test_keyword_only_below_bar_abstains(self):
|
|
63
|
+
# A single keyword hit (0.1) is below the 0.3 suggest bar → abstain.
|
|
64
|
+
e = self._one(score_suggestions(
|
|
65
|
+
[_iss(4, "sessions cleanup")],
|
|
66
|
+
[_trk("tabletop-sessions", name="tabletop sessions")],
|
|
67
|
+
))
|
|
68
|
+
self.assertEqual(e["verdict"], "abstain")
|
|
69
|
+
|
|
70
|
+
def test_no_signal_abstains(self):
|
|
71
|
+
e = self._one(score_suggestions(
|
|
72
|
+
[_iss(5, "totally unrelated billing thing")],
|
|
73
|
+
[_trk("auth-flow", milestone="v0.4")],
|
|
74
|
+
))
|
|
75
|
+
self.assertEqual(e["verdict"], "abstain")
|
|
76
|
+
self.assertNotIn("track", e)
|
|
77
|
+
|
|
78
|
+
def test_margin_narrow_when_two_tracks_tie(self):
|
|
79
|
+
# Both tracks share the milestone → equal top score → narrow margin.
|
|
80
|
+
e = self._one(score_suggestions(
|
|
81
|
+
[_iss(6, "x", milestone="v0.4")],
|
|
82
|
+
[_trk("auth-flow", milestone="v0.4"), _trk("idea-mode", milestone="v0.4")],
|
|
83
|
+
))
|
|
84
|
+
self.assertEqual(e["verdict"], "suggest")
|
|
85
|
+
self.assertEqual(e["margin"], "narrow")
|
|
86
|
+
self.assertIn("runner_up", e)
|
|
87
|
+
|
|
88
|
+
def test_margin_clear_when_one_track_dominates(self):
|
|
89
|
+
e = self._one(score_suggestions(
|
|
90
|
+
[_iss(7, "auth rate limit", milestone="v0.4", labels=["area/auth"])],
|
|
91
|
+
[_trk("auth-flow", name="auth flow", milestone="v0.4", labels=["area/auth"]),
|
|
92
|
+
_trk("idea-mode", milestone="v9.9")],
|
|
93
|
+
))
|
|
94
|
+
self.assertEqual(e["margin"], "clear")
|
|
95
|
+
self.assertEqual(e["track"], "auth-flow")
|
|
96
|
+
|
|
97
|
+
def test_confidence_clamped_and_in_range(self):
|
|
98
|
+
e = self._one(score_suggestions(
|
|
99
|
+
[_iss(8, "auth flow rate limit session token scope",
|
|
100
|
+
milestone="v0.4", labels=["area/auth"])],
|
|
101
|
+
[_trk("auth-flow", name="auth flow", milestone="v0.4",
|
|
102
|
+
scope="auth rate limit session token scope", labels=["area/auth"])],
|
|
103
|
+
))
|
|
104
|
+
self.assertLessEqual(e["confidence"], 1.0)
|
|
105
|
+
self.assertGreaterEqual(e["confidence"], 0.3)
|
|
106
|
+
|
|
107
|
+
def test_malformed_issue_number_skipped(self):
|
|
108
|
+
entries = score_suggestions([{"number": "not-an-int", "title": "x"}],
|
|
109
|
+
[_trk("a")])
|
|
110
|
+
self.assertEqual(entries, [])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
unittest.main()
|