@codyswann/lisa 3.14.4 → 3.14.6
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/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +14 -2
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +54 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +95 -0
- package/plugins/lisa/scripts/lifecycle-label-trust.mjs +494 -0
- package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +54 -0
- package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +95 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/lifecycle-label-trust.mjs +494 -0
- package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +54 -0
- package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +95 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/scripts/lifecycle-label-trust.mjs +494 -0
- package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +54 -0
- package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +95 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/scripts/lifecycle-label-trust.mjs +494 -0
- package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +54 -0
- package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +95 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/scripts/lifecycle-label-trust.mjs +494 -0
- package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +54 -0
- package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +95 -0
|
@@ -168,6 +168,60 @@ gh label list --repo <org>/<repo> --json name \
|
|
|
168
168
|
|
|
169
169
|
If none of the configured role labels exist on the repo → label convention not adopted, surface a setup error and exit. If the role labels exist but none are `$READY` on any open issue matching the resolved assignee filter (or any open issue when the filter is empty) → genuinely empty queue, exit cleanly with `"No GitHub issues labeled $READY. Nothing to do."`
|
|
170
170
|
|
|
171
|
+
#### 2b. Lifecycle-label trust resolution (bot-authored labels are not signals)
|
|
172
|
+
|
|
173
|
+
**A `status:*` label is only a lifecycle signal if a trustworthy actor applied it.** Third-party GitHub Apps write labels through the same API humans do, and at least one — `coderabbitai[bot]` — stamps `status:*` on issues seconds after filing. Measured on CodySwannGT/lisa#2460–#2540: seven of eight bot-applied lifecycle labels landed 26–118s after creation. Those labels are guesses wearing the costume of the intake contract, and they break the queue in **both** directions:
|
|
174
|
+
|
|
175
|
+
- a bot-applied **`$CLAIMED`** makes an unworked issue look like somebody's active work, so no cycle picks it up and no human re-examines it (#2470 sat unworked for a day this way);
|
|
176
|
+
- a bot-applied **`$READY`** puts an issue into the build queue that no human ever flipped ready (#2538), inverting the gate the ready role exists to be.
|
|
177
|
+
|
|
178
|
+
Before treating any candidate's lifecycle labels as meaningful, resolve which ones are trustworthy:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
TRUST_DIR=$(mktemp -d)
|
|
182
|
+
trap 'rm -rf "$TRUST_DIR"' EXIT
|
|
183
|
+
|
|
184
|
+
gh api "repos/<org>/<repo>/issues/<n>" > "$TRUST_DIR/issue.json"
|
|
185
|
+
|
|
186
|
+
# --paginate emits ONE ARRAY PER PAGE. Reading `$t[0]` would pass only page one,
|
|
187
|
+
# silently dropping later label events — the newest of which is exactly the
|
|
188
|
+
# application this classifier keys on. --slurp + `add` flattens all pages.
|
|
189
|
+
gh api "repos/<org>/<repo>/issues/<n>/timeline?per_page=100" --paginate --slurp \
|
|
190
|
+
> "$TRUST_DIR/pages.json"
|
|
191
|
+
jq 'add // []' "$TRUST_DIR/pages.json" > "$TRUST_DIR/timeline.json"
|
|
192
|
+
|
|
193
|
+
# Resolve config the SAME way `read_role` does: the local file overrides the
|
|
194
|
+
# global one key by key. Reading only `.lisa.config.json` would resolve a stale
|
|
195
|
+
# terminal `done` label for any project that overrides it locally, and
|
|
196
|
+
# `lisa-repair-intake` WRITES on the drift direction that mistake produces.
|
|
197
|
+
# Both files are optional — a missing one must not abort the cycle.
|
|
198
|
+
jq -s '(.[0] // {}) * (.[1] // {})' \
|
|
199
|
+
<(cat .lisa.config.json 2>/dev/null || echo '{}') \
|
|
200
|
+
<(cat .lisa.config.local.json 2>/dev/null || echo '{}') \
|
|
201
|
+
> "$TRUST_DIR/config.json"
|
|
202
|
+
|
|
203
|
+
jq -n --slurpfile i "$TRUST_DIR/issue.json" \
|
|
204
|
+
--slurpfile t "$TRUST_DIR/timeline.json" \
|
|
205
|
+
--slurpfile c "$TRUST_DIR/config.json" \
|
|
206
|
+
'{issue: $i[0], timeline: $t[0], config: $c[0]}' \
|
|
207
|
+
> "$TRUST_DIR/input.json"
|
|
208
|
+
|
|
209
|
+
node "${CLAUDE_PLUGIN_ROOT}/scripts/lifecycle-label-trust.mjs" < "$TRUST_DIR/input.json"
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The temp directory is **per invocation**. Fixed `/tmp/lisa-*.json` paths let two concurrent intake cycles interleave one issue's metadata with another's timeline, which yields a confident verdict about an item that was never examined.
|
|
213
|
+
|
|
214
|
+
The classifier returns `trusted`, `untrusted`, `unknownProvenance`, and a per-label `evaluated` list carrying the actor and the latency behind each decision. **Use `trusted` wherever this skill would otherwise read the raw label set**, and specifically:
|
|
215
|
+
|
|
216
|
+
- a candidate whose `$READY` is **untrusted** is **not claimable** — skip it and leave it for a human to flip genuinely ready; report it in the summary rather than dispatching it;
|
|
217
|
+
- a candidate whose `$CLAIMED` is **untrusted** is **not claimed** — if its `$READY` is trusted it stays a normal candidate, exactly as if the bot label were absent.
|
|
218
|
+
|
|
219
|
+
**Never unlabel to correct this.** The bot re-applies its label on each subsequent review event, so reverting produces a label-flap loop that is worse than the defect. The guard is that intake stops *believing* the label; it writes nothing. Distrust is idempotent and cannot race.
|
|
220
|
+
|
|
221
|
+
A label whose provenance cannot be established (applied at creation, which GitHub records no `labeled` event for) is trusted but listed in `unknownProvenance` — failing closed there would ignore the human `status:ready` that opens the queue. Report that list; do not silently drop it.
|
|
222
|
+
|
|
223
|
+
Lifecycle membership is decided by the **`status:` prefix**, never by a pinned member list. The live family has drifted 7 → 6 members, and a literal set breaks silently: an unrecognised member simply fails to match, so the guard would report clean on exactly the case it stopped covering.
|
|
224
|
+
|
|
171
225
|
### Phase 3 — Process the first eligible ready issue
|
|
172
226
|
|
|
173
227
|
#### 3a.0 Repo-scope gate (claim only current-repo issues)
|
|
@@ -560,6 +560,101 @@ native-open / active / unresolved:
|
|
|
560
560
|
4. Post a compact `[lisa-repair-intake]` note only when the native close-out changed state or when
|
|
561
561
|
an actionable setup error must be surfaced. Do not spam already-closed terminal items.
|
|
562
562
|
|
|
563
|
+
### Lifecycle label contradicts native state — walk BOTH directions
|
|
564
|
+
|
|
565
|
+
A lifecycle label can lie in two ways, and historically only one of them had an owner:
|
|
566
|
+
|
|
567
|
+
| label | native state | owned by |
|
|
568
|
+
|---|---|---|
|
|
569
|
+
| terminal `done` role | still open / active | the `Build terminal-open` section above |
|
|
570
|
+
| non-terminal role (`ready`, `claimed`, `blocked`, or any other `status:` member) | closed / completed | **nothing, until now** |
|
|
571
|
+
|
|
572
|
+
A pass that walks one direction leaves the other accumulating silently. Measured on the Linear TUN
|
|
573
|
+
board: **TUN-556 and TUN-503 both carry a terminal `status:done` while still natively open in
|
|
574
|
+
`On Dev`** — invisible to intake and indistinguishable from handled, exactly like a bot-applied
|
|
575
|
+
`status:in-progress` on GitHub.
|
|
576
|
+
|
|
577
|
+
Resolve both directions in one pass with the shipped detector, which always reports
|
|
578
|
+
`directionsWalked` covering both, so a "clean" verdict is an assertion that both were examined.
|
|
579
|
+
|
|
580
|
+
**Build the classifier input inside this cycle. Never consume a temp file another skill wrote.**
|
|
581
|
+
repair-intake runs as its own cycle, so borrowing `lisa-github-build-intake`'s scratch file fails
|
|
582
|
+
two ways: absent, the redirect fails and `set -e` aborts the whole cycle; stale from an earlier run
|
|
583
|
+
on a *different* item, the resolver classifies that other item and this section reports `drifts: []`
|
|
584
|
+
while `directionsWalked` still claims both directions were walked. That is precisely the
|
|
585
|
+
silent-clean verdict this section promises cannot happen.
|
|
586
|
+
|
|
587
|
+
```bash
|
|
588
|
+
TRUST_DIR=$(mktemp -d)
|
|
589
|
+
trap 'rm -rf "$TRUST_DIR"' EXIT
|
|
590
|
+
|
|
591
|
+
gh api "repos/<org>/<repo>/issues/<n>" > "$TRUST_DIR/issue.json"
|
|
592
|
+
|
|
593
|
+
# --paginate emits ONE ARRAY PER PAGE; --slurp + `add` flattens all of them so a
|
|
594
|
+
# label event on page two is not silently dropped.
|
|
595
|
+
gh api "repos/<org>/<repo>/issues/<n>/timeline?per_page=100" --paginate --slurp \
|
|
596
|
+
> "$TRUST_DIR/pages.json"
|
|
597
|
+
jq 'add // []' "$TRUST_DIR/pages.json" > "$TRUST_DIR/timeline.json"
|
|
598
|
+
|
|
599
|
+
# Same resolution `read_role` uses: local overrides global, both optional.
|
|
600
|
+
# Resolving a stale terminal `done` here would make the true terminal label look
|
|
601
|
+
# like `open-label-closed-state` — and that direction WRITES.
|
|
602
|
+
jq -s '(.[0] // {}) * (.[1] // {})' \
|
|
603
|
+
<(cat .lisa.config.json 2>/dev/null || echo '{}') \
|
|
604
|
+
<(cat .lisa.config.local.json 2>/dev/null || echo '{}') \
|
|
605
|
+
> "$TRUST_DIR/config.json"
|
|
606
|
+
|
|
607
|
+
jq -n --slurpfile i "$TRUST_DIR/issue.json" \
|
|
608
|
+
--slurpfile t "$TRUST_DIR/timeline.json" \
|
|
609
|
+
--slurpfile c "$TRUST_DIR/config.json" \
|
|
610
|
+
'{issue: $i[0], timeline: $t[0], config: $c[0]}' \
|
|
611
|
+
> "$TRUST_DIR/input.json"
|
|
612
|
+
|
|
613
|
+
node "${CLAUDE_PLUGIN_ROOT}/scripts/lifecycle-label-trust.mjs" < "$TRUST_DIR/input.json"
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
**Untrusted labels are excluded from `drifts` before any repair runs** — the classifier does this
|
|
617
|
+
itself, so repair must **never act on a label the classifier refused to believe**. Without it, a
|
|
618
|
+
bot-applied label on a natively closed item would be advanced to the terminal `done` role by the
|
|
619
|
+
`open-label-closed-state` repair, laundering the exact input the guard rejected into a real write.
|
|
620
|
+
The `excluded` array reports what was held back; surface it, do not repair it.
|
|
621
|
+
|
|
622
|
+
1. **`terminal-label-open-state`** → hand to the `Build terminal-open → native close` section above.
|
|
623
|
+
Do not restate its rules; the terminality test lives there (intermediate env rungs like
|
|
624
|
+
`status:on-dev` / `status:on-stg` are **not** terminal).
|
|
625
|
+
2. **`open-label-closed-state`** → the previously unowned direction. The item is natively closed or
|
|
626
|
+
completed while still wearing a non-terminal lifecycle role. Advance the label to the env-resolved
|
|
627
|
+
terminal `done` role and post one idempotent `[lisa-repair-intake]` note. This is a write on
|
|
628
|
+
**Lisa's own** lifecycle surface, not a contest with another writer.
|
|
629
|
+
Apply the leaf/container check from the **Lifecycle ownership guard** section *before* this
|
|
630
|
+
branch: repair-intake owns container repair, so a natively-closed `ready` **leaf** is skipped
|
|
631
|
+
here and left to the build lane rather than claimed by this pass.
|
|
632
|
+
3. **Vendor caveat — Linear.** On Linear the lifecycle surface is the native workflow **state**, not a
|
|
633
|
+
`status:*` label (see `config-resolution`). A `status:*` label there is leftover cruft that no
|
|
634
|
+
repair direction reads, which is precisely why TUN-556 and TUN-503 rotted. Treat Linear's native
|
|
635
|
+
state as authoritative and remove the contradicting stale label; never move the state to match a
|
|
636
|
+
label.
|
|
637
|
+
|
|
638
|
+
Membership in the lifecycle namespace is decided by the **`status:` prefix**, never a pinned member
|
|
639
|
+
list — the live family has drifted 7 → 6 members, and a literal set fails silently by simply not
|
|
640
|
+
matching the member it stopped covering.
|
|
641
|
+
|
|
642
|
+
### Bot-authored lifecycle label → distrust, never revert (read-only)
|
|
643
|
+
|
|
644
|
+
`status:*` labels written by a **bot** actor within the plausibility window of the item's creation are
|
|
645
|
+
not lifecycle signals — see `lisa-github-build-intake` Phase 2b for the measurement and the
|
|
646
|
+
classifier. Report them here so an operator can see how much of the board is wearing a label nobody
|
|
647
|
+
chose:
|
|
648
|
+
|
|
649
|
+
- list each item alongside the untrusted label, the bot login, and the latency, from the classifier's
|
|
650
|
+
`untrusted` array;
|
|
651
|
+
- list `unknownProvenance` labels separately — trusted, but unattributable;
|
|
652
|
+
- **do not unlabel and do not relabel.** The bot re-applies on each subsequent review event, so a
|
|
653
|
+
reverting reconciler produces a label-flap loop that is worse than the defect. Repair happens by
|
|
654
|
+
intake declining to *believe* the label, which is idempotent and writes nothing.
|
|
655
|
+
|
|
656
|
+
This class is deliberately **read-only** and never counts toward the repair cap's actionable work.
|
|
657
|
+
|
|
563
658
|
### Build parent rollup reconciliation (intermediate-env or terminal close-out)
|
|
564
659
|
|
|
565
660
|
For each parent/container item (an Epic, a Linear Project, or any item — of any type — with open child work),
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Lifecycle-label trust resolution — the read-side guard for #2539.
|
|
4
|
+
*
|
|
5
|
+
* ## The defect
|
|
6
|
+
*
|
|
7
|
+
* `coderabbitai[bot]` stamps a `status:*` label on issues seconds after they are
|
|
8
|
+
* filed. Measured across CodySwannGT/lisa#2460–#2540, seven of eight bot-applied
|
|
9
|
+
* lifecycle labels landed 26–118s after creation — far too fast to be a real
|
|
10
|
+
* claim. An issue wearing a bot-applied `status:in-progress` is invisible to
|
|
11
|
+
* build-intake (it is not the `ready` role) AND looks handled to a human, so
|
|
12
|
+
* nothing re-examines it. A MISSING lifecycle label is recoverable; a WRONG one
|
|
13
|
+
* is silent in both directions at once.
|
|
14
|
+
*
|
|
15
|
+
* ## Why this guard reads instead of writes
|
|
16
|
+
*
|
|
17
|
+
* The label is applied by a webhook after the fact, so no pre-tool check on this
|
|
18
|
+
* machine has anything to intercept — the write never passes through us. The two
|
|
19
|
+
* remaining options were a reconciler that reverts the label, and this: intake
|
|
20
|
+
* refusing to BELIEVE the label. The reconciler was rejected because the bot
|
|
21
|
+
* re-applies on every subsequent review event, so reverting produces a label-flap
|
|
22
|
+
* loop that is worse than the disease. Distrust is idempotent, cannot race, and
|
|
23
|
+
* writes nothing — nothing in this module mutates a tracker.
|
|
24
|
+
*
|
|
25
|
+
* ## Why actor alone is not enough
|
|
26
|
+
*
|
|
27
|
+
* #2470 (false claim, 27s) and #2494 (real, 3001s) were BOTH applied by
|
|
28
|
+
* `coderabbitai[bot]`. Rejecting every bot-authored lifecycle label would
|
|
29
|
+
* discard the true one; rejecting every fast label would discard the human
|
|
30
|
+
* `status:ready` applied at filing time, which is the queue's entry signal.
|
|
31
|
+
* Distrust therefore requires BOTH conditions: a bot actor AND a latency below
|
|
32
|
+
* the plausibility window.
|
|
33
|
+
*
|
|
34
|
+
* ## Why the member set is never hardcoded
|
|
35
|
+
*
|
|
36
|
+
* The live `status:*` family has drifted 7 → 6 members. A pinned literal breaks
|
|
37
|
+
* silently — an unrecognised member simply fails to match, so the guard reports
|
|
38
|
+
* clean on exactly the case it stopped covering. Membership is decided by the
|
|
39
|
+
* `status:` PREFIX and terminality is resolved from live config.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** Lifecycle labels are identified by this prefix, never by a pinned set. */
|
|
43
|
+
export const LIFECYCLE_LABEL_PREFIX = "status:";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fallback terminal label used only when config supplies no `done` role.
|
|
47
|
+
*
|
|
48
|
+
* This module ships standalone into host projects and cannot import Lisa's
|
|
49
|
+
* TypeScript `BUILD_LABEL_DEFAULTS`, so the literal is repeated here — but it is
|
|
50
|
+
* NOT free to drift: `lifecycle-label-trust.test.ts` asserts it stays equal to
|
|
51
|
+
* `BUILD_LABEL_DEFAULTS.done.production` in `src/sync/lifecycle-defaults.ts`.
|
|
52
|
+
* This is a fallback only; a configured project resolves the value live.
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_TERMINAL_LIFECYCLE_LABEL = "status:done";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Seconds below which a BOT-applied lifecycle label is not believable as a claim.
|
|
58
|
+
*
|
|
59
|
+
* Calibrated against measurement, not taste: the observed false bot claims
|
|
60
|
+
* clustered at 26–118s, while the one corroborated bot label landed at 3001s.
|
|
61
|
+
* 300s sits an order of magnitude above the noise and an order of magnitude
|
|
62
|
+
* below the real signal, so neither edge is close to the boundary.
|
|
63
|
+
*/
|
|
64
|
+
export const IMPLAUSIBLE_CLAIM_WINDOW_SECONDS = 300;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Both directions a lifecycle label can contradict native state.
|
|
68
|
+
*
|
|
69
|
+
* Emitted on every drift result so a repair pass cannot walk one direction and
|
|
70
|
+
* silently leave the other accumulating — the asymmetry that let TUN-556 and
|
|
71
|
+
* TUN-503 sit on a terminal `status:done` while still natively open.
|
|
72
|
+
*/
|
|
73
|
+
export const LIFECYCLE_DRIFT_DIRECTIONS = [
|
|
74
|
+
"terminal-label-open-state",
|
|
75
|
+
"open-label-closed-state",
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {{ readonly login?: string, readonly type?: string }} Actor
|
|
80
|
+
*
|
|
81
|
+
* @typedef {{
|
|
82
|
+
* readonly event?: string
|
|
83
|
+
* readonly label?: { readonly name?: string }
|
|
84
|
+
* readonly actor?: Actor | null
|
|
85
|
+
* readonly created_at?: string
|
|
86
|
+
* }} TimelineEvent
|
|
87
|
+
*
|
|
88
|
+
* @typedef {{
|
|
89
|
+
* readonly label: string
|
|
90
|
+
* readonly actor: string | null
|
|
91
|
+
* readonly latencySeconds: number | null
|
|
92
|
+
* readonly reason: string
|
|
93
|
+
* readonly trusted: boolean
|
|
94
|
+
* }} EvaluatedLabel
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Is this label part of the lifecycle namespace?
|
|
99
|
+
*
|
|
100
|
+
* Prefix-only by design — see the module preamble on set drift. `status:` with
|
|
101
|
+
* no member is still lifecycle-namespaced and must not fall through as taxonomy.
|
|
102
|
+
*
|
|
103
|
+
* @param {unknown} name
|
|
104
|
+
* @returns {boolean}
|
|
105
|
+
*/
|
|
106
|
+
export function isLifecycleLabel(name) {
|
|
107
|
+
if (typeof name !== "string") {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return name.trim().toLowerCase().startsWith(LIFECYCLE_LABEL_PREFIX);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Is this timeline actor a bot?
|
|
115
|
+
*
|
|
116
|
+
* Checks GitHub's first-class `type` discriminator first, then falls back to the
|
|
117
|
+
* `[bot]` login suffix so an actor payload lacking `type` is still classified.
|
|
118
|
+
* A missing actor is NOT a bot: unattributable is handled separately, and
|
|
119
|
+
* guessing "bot" there would silently drop legitimate labels.
|
|
120
|
+
*
|
|
121
|
+
* @param {Actor | null | undefined} actor
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
export function isBotActor(actor) {
|
|
125
|
+
if (!actor || typeof actor !== "object") {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (typeof actor.type === "string" && actor.type.toLowerCase() === "bot") {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
typeof actor.login === "string" &&
|
|
133
|
+
actor.login.trim().toLowerCase().endsWith("[bot]")
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolve which of an item's lifecycle labels intake is entitled to believe.
|
|
139
|
+
*
|
|
140
|
+
* Read-only and total: every lifecycle label on the item lands in exactly one of
|
|
141
|
+
* `trusted` or `untrusted`, and `evaluated` carries the reasoning for all of
|
|
142
|
+
* them so a distrust decision is auditable rather than invisible.
|
|
143
|
+
*
|
|
144
|
+
* Provenance that cannot be established (a label applied at creation, which
|
|
145
|
+
* GitHub records no `labeled` event for) is TRUSTED but reported in
|
|
146
|
+
* `unknownProvenance`. Failing closed there would make intake ignore the
|
|
147
|
+
* human `status:ready` that opens the queue, stalling every project — so the
|
|
148
|
+
* blind spot is surfaced rather than guessed at. Supplying `issueAuthor` closes
|
|
149
|
+
* it, by attributing creation-time labels to whoever filed the item.
|
|
150
|
+
*
|
|
151
|
+
* @param {{
|
|
152
|
+
* readonly labels?: readonly (string | { readonly name?: string })[]
|
|
153
|
+
* readonly timeline?: readonly TimelineEvent[]
|
|
154
|
+
* readonly issueCreatedAt?: string
|
|
155
|
+
* readonly issueAuthor?: Actor | null
|
|
156
|
+
* readonly windowSeconds?: number
|
|
157
|
+
* }} input
|
|
158
|
+
* @returns {{
|
|
159
|
+
* readonly trusted: readonly string[]
|
|
160
|
+
* readonly untrusted: readonly Omit<EvaluatedLabel, "trusted">[]
|
|
161
|
+
* readonly unknownProvenance: readonly string[]
|
|
162
|
+
* readonly evaluated: readonly EvaluatedLabel[]
|
|
163
|
+
* readonly hasUntrustedLifecycleLabels: boolean
|
|
164
|
+
* }}
|
|
165
|
+
*/
|
|
166
|
+
export function resolveTrustedLifecycleLabels(input = {}) {
|
|
167
|
+
const windowSeconds = normalizeWindow(input.windowSeconds);
|
|
168
|
+
const createdAtMs = parseTimestamp(input.issueCreatedAt);
|
|
169
|
+
const lifecycleLabels = normalizeLabelNames(input.labels).filter(
|
|
170
|
+
isLifecycleLabel
|
|
171
|
+
);
|
|
172
|
+
const latestApplication = indexLatestLabelApplications(input.timeline);
|
|
173
|
+
|
|
174
|
+
const evaluated = lifecycleLabels.map(label =>
|
|
175
|
+
evaluateLabel({
|
|
176
|
+
label,
|
|
177
|
+
application: latestApplication.get(label.trim().toLowerCase()),
|
|
178
|
+
createdAtMs,
|
|
179
|
+
issueAuthor: input.issueAuthor,
|
|
180
|
+
windowSeconds,
|
|
181
|
+
})
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
trusted: evaluated.filter(entry => entry.trusted).map(entry => entry.label),
|
|
186
|
+
untrusted: evaluated
|
|
187
|
+
.filter(entry => !entry.trusted)
|
|
188
|
+
.map(({ trusted: _trusted, ...rest }) => rest),
|
|
189
|
+
unknownProvenance: evaluated
|
|
190
|
+
.filter(entry => entry.reason === "provenance-unknown")
|
|
191
|
+
.map(entry => entry.label),
|
|
192
|
+
evaluated,
|
|
193
|
+
hasUntrustedLifecycleLabels: evaluated.some(entry => !entry.trusted),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The lifecycle labels that mean "this work is finished", read from live config.
|
|
199
|
+
*
|
|
200
|
+
* Only the PRODUCTION rung of the `done` role is terminal. `status:on-dev` and
|
|
201
|
+
* `status:on-stg` are shipped-somewhere, not finished, and closing on them would
|
|
202
|
+
* retire work that still has environments to clear.
|
|
203
|
+
*
|
|
204
|
+
* @param {unknown} config a parsed `.lisa.config.json`-shaped object
|
|
205
|
+
* @returns {readonly string[]}
|
|
206
|
+
*/
|
|
207
|
+
export function terminalLifecycleLabels(config) {
|
|
208
|
+
const done = readPath(config, ["github", "labels", "build", "done"]);
|
|
209
|
+
|
|
210
|
+
if (typeof done === "string" && done.trim().length > 0) {
|
|
211
|
+
return [done.trim()];
|
|
212
|
+
}
|
|
213
|
+
const production = readPath(done, ["production"]);
|
|
214
|
+
if (typeof production === "string" && production.trim().length > 0) {
|
|
215
|
+
return [production.trim()];
|
|
216
|
+
}
|
|
217
|
+
return [DEFAULT_TERMINAL_LIFECYCLE_LABEL];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Detect lifecycle labels that contradict native state, in BOTH directions.
|
|
222
|
+
*
|
|
223
|
+
* `directionsWalked` is always the full direction list, so a caller reporting
|
|
224
|
+
* "clean" is asserting it looked at both — the failure mode refinement #1 of
|
|
225
|
+
* issue #2539 describes, where only the auto-complete direction was ever
|
|
226
|
+
* repaired and the reverse rotted unobserved.
|
|
227
|
+
*
|
|
228
|
+
* `excludeLabels` removes labels from consideration entirely. Callers MUST pass
|
|
229
|
+
* the untrusted set here: `open-label-closed-state` is a repair direction that
|
|
230
|
+
* WRITES (it advances the label to the terminal `done` role), so without this a
|
|
231
|
+
* bot-applied label the classifier just refused to believe would still drive a
|
|
232
|
+
* real label write — the guard would launder the very input it rejected.
|
|
233
|
+
*
|
|
234
|
+
* @param {{
|
|
235
|
+
* readonly labels?: readonly (string | { readonly name?: string })[]
|
|
236
|
+
* readonly state?: string
|
|
237
|
+
* readonly terminalLabels?: readonly string[]
|
|
238
|
+
* readonly excludeLabels?: readonly string[]
|
|
239
|
+
* }} input
|
|
240
|
+
* @returns {{
|
|
241
|
+
* readonly drifts: readonly { direction: string, label: string }[]
|
|
242
|
+
* readonly directionsWalked: readonly string[]
|
|
243
|
+
* readonly excluded: readonly string[]
|
|
244
|
+
* }}
|
|
245
|
+
*/
|
|
246
|
+
export function detectLifecycleDrift(input = {}) {
|
|
247
|
+
const terminal = new Set(
|
|
248
|
+
(input.terminalLabels ?? terminalLifecycleLabels(undefined)).map(label =>
|
|
249
|
+
String(label).trim().toLowerCase()
|
|
250
|
+
)
|
|
251
|
+
);
|
|
252
|
+
const excluded = new Set(
|
|
253
|
+
(Array.isArray(input.excludeLabels) ? input.excludeLabels : []).map(label =>
|
|
254
|
+
String(label).trim().toLowerCase()
|
|
255
|
+
)
|
|
256
|
+
);
|
|
257
|
+
const closed =
|
|
258
|
+
String(input.state ?? "")
|
|
259
|
+
.trim()
|
|
260
|
+
.toLowerCase() === "closed";
|
|
261
|
+
const allLifecycleLabels = normalizeLabelNames(input.labels).filter(
|
|
262
|
+
isLifecycleLabel
|
|
263
|
+
);
|
|
264
|
+
const skipped = allLifecycleLabels.filter(label =>
|
|
265
|
+
excluded.has(label.trim().toLowerCase())
|
|
266
|
+
);
|
|
267
|
+
const lifecycleLabels = allLifecycleLabels.filter(
|
|
268
|
+
label => !excluded.has(label.trim().toLowerCase())
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const drifts = lifecycleLabels.flatMap(label => {
|
|
272
|
+
const isTerminal = terminal.has(label.trim().toLowerCase());
|
|
273
|
+
if (isTerminal && !closed) {
|
|
274
|
+
return [{ direction: "terminal-label-open-state", label }];
|
|
275
|
+
}
|
|
276
|
+
if (!isTerminal && closed) {
|
|
277
|
+
return [{ direction: "open-label-closed-state", label }];
|
|
278
|
+
}
|
|
279
|
+
return [];
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
drifts,
|
|
284
|
+
directionsWalked: LIFECYCLE_DRIFT_DIRECTIONS,
|
|
285
|
+
excluded: skipped,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Classify one lifecycle label against its most recent application.
|
|
291
|
+
*
|
|
292
|
+
* @param {{
|
|
293
|
+
* label: string
|
|
294
|
+
* application?: { actor?: Actor | null, createdAtMs: number | null }
|
|
295
|
+
* createdAtMs: number | null
|
|
296
|
+
* issueAuthor?: Actor | null
|
|
297
|
+
* windowSeconds: number
|
|
298
|
+
* }} params
|
|
299
|
+
* @returns {EvaluatedLabel}
|
|
300
|
+
*/
|
|
301
|
+
function evaluateLabel(params) {
|
|
302
|
+
const { label, application, createdAtMs, issueAuthor, windowSeconds } =
|
|
303
|
+
params;
|
|
304
|
+
|
|
305
|
+
const actor = application ? application.actor : issueAuthor;
|
|
306
|
+
const hasProvenance = Boolean(application) || Boolean(issueAuthor);
|
|
307
|
+
|
|
308
|
+
if (!hasProvenance) {
|
|
309
|
+
return {
|
|
310
|
+
label,
|
|
311
|
+
actor: null,
|
|
312
|
+
latencySeconds: null,
|
|
313
|
+
reason: "provenance-unknown",
|
|
314
|
+
trusted: true,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const login = typeof actor?.login === "string" ? actor.login : null;
|
|
319
|
+
|
|
320
|
+
if (!isBotActor(actor)) {
|
|
321
|
+
return {
|
|
322
|
+
label,
|
|
323
|
+
actor: login,
|
|
324
|
+
latencySeconds: latencySeconds(createdAtMs, application),
|
|
325
|
+
reason: "human-actor",
|
|
326
|
+
trusted: true,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const latency = latencySeconds(createdAtMs, application);
|
|
331
|
+
const implausible = latency === null || latency < windowSeconds;
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
label,
|
|
335
|
+
actor: login,
|
|
336
|
+
latencySeconds: latency,
|
|
337
|
+
reason: implausible
|
|
338
|
+
? "bot-actor-implausible-latency"
|
|
339
|
+
: "bot-actor-plausible-latency",
|
|
340
|
+
trusted: !implausible,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Seconds between item creation and the label application.
|
|
346
|
+
*
|
|
347
|
+
* A creation-time label (no timeline event) is latency 0 by construction.
|
|
348
|
+
*
|
|
349
|
+
* @param {number | null} createdAtMs
|
|
350
|
+
* @param {{ createdAtMs: number | null } | undefined} application
|
|
351
|
+
* @returns {number | null}
|
|
352
|
+
*/
|
|
353
|
+
function latencySeconds(createdAtMs, application) {
|
|
354
|
+
if (createdAtMs === null) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
if (!application) {
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
if (application.createdAtMs === null) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
return Math.max(
|
|
364
|
+
0,
|
|
365
|
+
Math.round((application.createdAtMs - createdAtMs) / 1000)
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Map each lifecycle label to its MOST RECENT `labeled` event.
|
|
371
|
+
*
|
|
372
|
+
* Most-recent, not first: a human who removes the bot's label and re-applies it
|
|
373
|
+
* has made a genuine claim, and keying on the first application would keep
|
|
374
|
+
* distrusting a label the human now owns.
|
|
375
|
+
*
|
|
376
|
+
* @param {readonly TimelineEvent[] | undefined} timeline
|
|
377
|
+
* @returns {Map<string, { actor?: Actor | null, createdAtMs: number | null }>}
|
|
378
|
+
*/
|
|
379
|
+
function indexLatestLabelApplications(timeline) {
|
|
380
|
+
const index = new Map();
|
|
381
|
+
|
|
382
|
+
for (const event of Array.isArray(timeline) ? timeline : []) {
|
|
383
|
+
if (event?.event !== "labeled") {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const name = event.label?.name;
|
|
387
|
+
if (typeof name !== "string" || name.trim().length === 0) {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const key = name.trim().toLowerCase();
|
|
391
|
+
const createdAtMs = parseTimestamp(event.created_at);
|
|
392
|
+
const existing = index.get(key);
|
|
393
|
+
if (
|
|
394
|
+
existing &&
|
|
395
|
+
existing.createdAtMs !== null &&
|
|
396
|
+
createdAtMs !== null &&
|
|
397
|
+
existing.createdAtMs > createdAtMs
|
|
398
|
+
) {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
index.set(key, { actor: event.actor ?? null, createdAtMs });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return index;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* @param {readonly (string | { readonly name?: string })[] | undefined} labels
|
|
409
|
+
* @returns {readonly string[]}
|
|
410
|
+
*/
|
|
411
|
+
function normalizeLabelNames(labels) {
|
|
412
|
+
return (Array.isArray(labels) ? labels : [])
|
|
413
|
+
.map(label => (typeof label === "string" ? label : label?.name))
|
|
414
|
+
.filter(name => typeof name === "string" && name.trim().length > 0);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* @param {unknown} value
|
|
419
|
+
* @returns {number | null}
|
|
420
|
+
*/
|
|
421
|
+
function parseTimestamp(value) {
|
|
422
|
+
if (typeof value !== "string") {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
const parsed = Date.parse(value);
|
|
426
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* @param {unknown} value
|
|
431
|
+
* @returns {number}
|
|
432
|
+
*/
|
|
433
|
+
function normalizeWindow(value) {
|
|
434
|
+
return Number.isFinite(value) && value >= 0
|
|
435
|
+
? Number(value)
|
|
436
|
+
: IMPLAUSIBLE_CLAIM_WINDOW_SECONDS;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* @param {unknown} source
|
|
441
|
+
* @param {readonly string[]} keys
|
|
442
|
+
* @returns {unknown}
|
|
443
|
+
*/
|
|
444
|
+
function readPath(source, keys) {
|
|
445
|
+
return keys.reduce(
|
|
446
|
+
(current, key) =>
|
|
447
|
+
current && typeof current === "object" ? current[key] : undefined,
|
|
448
|
+
source
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* CLI: read a `{ issue, timeline, config }` payload on stdin, print the verdict.
|
|
454
|
+
*
|
|
455
|
+
* Exits 0 even when untrusted labels are found. This is a classifier, not a
|
|
456
|
+
* gate: callers run it inside `set -e` intake bash and branch on the JSON, and a
|
|
457
|
+
* non-zero exit would abort the scan on the very issues it exists to rescue.
|
|
458
|
+
*
|
|
459
|
+
* @returns {Promise<void>}
|
|
460
|
+
*/
|
|
461
|
+
async function main() {
|
|
462
|
+
const chunks = [];
|
|
463
|
+
for await (const chunk of process.stdin) {
|
|
464
|
+
chunks.push(chunk);
|
|
465
|
+
}
|
|
466
|
+
const payload = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
467
|
+
const issue = payload.issue ?? {};
|
|
468
|
+
|
|
469
|
+
const trust = resolveTrustedLifecycleLabels({
|
|
470
|
+
labels: issue.labels,
|
|
471
|
+
timeline: payload.timeline,
|
|
472
|
+
issueCreatedAt: issue.created_at ?? issue.createdAt,
|
|
473
|
+
issueAuthor: issue.user ?? issue.author,
|
|
474
|
+
windowSeconds: payload.windowSeconds,
|
|
475
|
+
});
|
|
476
|
+
// Untrusted labels are excluded here rather than at the call site: the
|
|
477
|
+
// `open-label-closed-state` direction WRITES, so a caller that forgot to pass
|
|
478
|
+
// them would launder a label the classifier just rejected into a real write.
|
|
479
|
+
const drift = detectLifecycleDrift({
|
|
480
|
+
labels: issue.labels,
|
|
481
|
+
state: issue.state,
|
|
482
|
+
terminalLabels: terminalLifecycleLabels(payload.config),
|
|
483
|
+
excludeLabels: trust.untrusted.map(entry => entry.label),
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
process.stdout.write(`${JSON.stringify({ ...trust, ...drift }, null, 2)}\n`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
490
|
+
main().catch(error => {
|
|
491
|
+
process.stderr.write(`${error.message}\n`);
|
|
492
|
+
process.exitCode = 1;
|
|
493
|
+
});
|
|
494
|
+
}
|