@codyswann/lisa 4.71.5 → 4.71.7
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/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +20 -2
- package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +71 -10
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +10 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +12 -8
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/block-managed-file-edits.sh +71 -10
- package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +8 -2
- package/package.json +4 -4
- 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-drive-pr-to-merge/SKILL.md +89 -5
- package/plugins/lisa/.codex-plugin/skills/lisa-queue-status/SKILL.md +43 -3
- package/plugins/lisa/hooks/block-direct-issue-create.sh +20 -2
- package/plugins/lisa/hooks/block-managed-file-edits.sh +71 -10
- package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +89 -5
- package/plugins/lisa/skills/lisa-queue-status/SKILL.md +43 -3
- package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +20 -2
- package/plugins/lisa-agy/hooks/block-managed-file-edits.sh +71 -10
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +89 -5
- package/plugins/lisa-agy/skills/lisa-queue-status/SKILL.md +43 -3
- 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/hooks/block-direct-issue-create.sh +20 -2
- package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +71 -10
- package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +89 -5
- package/plugins/lisa-copilot/skills/lisa-queue-status/SKILL.md +43 -3
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +20 -2
- package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +71 -10
- package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +89 -5
- package/plugins/lisa-cursor/skills/lisa-queue-status/SKILL.md +43 -3
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/skills/expo-router-best-practices/references/static-export-verification.md +1 -1
- package/plugins/lisa-expo/skills/expo-router-best-practices/references/static-export-verification.md +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-agy/skills/expo-router-best-practices/references/static-export-verification.md +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/skills/expo-router-best-practices/references/static-export-verification.md +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/skills/expo-router-best-practices/references/static-export-verification.md +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/hooks/block-direct-issue-create.sh +20 -2
- package/plugins/src/base/hooks/block-managed-file-edits.sh +71 -10
- package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +89 -5
- package/plugins/src/base/skills/lisa-queue-status/SKILL.md +43 -3
- package/plugins/src/expo/skills/expo-router-best-practices/references/static-export-verification.md +1 -1
- package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +22 -3
|
@@ -86,11 +86,51 @@ Alongside the two work queues, report the **arming state of the repo's open pull
|
|
|
86
86
|
Run the sweep rather than eyeballing the list — an LLM reading PR pages one at a time is exactly the shell-loop-by-prose failure #3512 records:
|
|
87
87
|
|
|
88
88
|
```bash
|
|
89
|
-
gh
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
repository=$(gh repo view --json nameWithOwner --jq .nameWithOwner) || {
|
|
90
|
+
printf '%s\n' 'NOT_MEASURED: could not resolve the current repository.' >&2
|
|
91
|
+
exit 2
|
|
92
|
+
}
|
|
93
|
+
pages=$(gh api --paginate --slurp \
|
|
94
|
+
"repos/$repository/pulls?state=open&per_page=100") || {
|
|
95
|
+
printf '%s\n' 'NOT_MEASURED: could not read every open pull request page.' >&2
|
|
96
|
+
exit 2
|
|
97
|
+
}
|
|
98
|
+
prs=$(printf '%s\n' "$pages" | jq -ce '
|
|
99
|
+
def complete_pr:
|
|
100
|
+
try (
|
|
101
|
+
has("auto_merge") and
|
|
102
|
+
(.auto_merge == null or
|
|
103
|
+
(.auto_merge.merge_method as $method | ["merge", "squash", "rebase"] |
|
|
104
|
+
index($method)) != null) and
|
|
105
|
+
(.number | type) == "number" and .number > 0 and
|
|
106
|
+
(.title | type) == "string" and (.html_url | type) == "string" and
|
|
107
|
+
(.draft | type) == "boolean" and has("body") and
|
|
108
|
+
(.body == null or (.body | type) == "string") and
|
|
109
|
+
(.labels | type) == "array" and all(.labels[]; (.name | type) == "string")
|
|
110
|
+
) catch false;
|
|
111
|
+
if type != "array" then error("Invalid PR page response")
|
|
112
|
+
elif length == 0 or any(.[]; type != "array") then error("Missing PR pages")
|
|
113
|
+
elif any(.[][]; complete_pr | not) then error("Incomplete PR metadata")
|
|
114
|
+
else [.[][] | {
|
|
115
|
+
number, title, url: .html_url, isDraft: .draft, labels, body,
|
|
116
|
+
autoMergeRequest: .auto_merge
|
|
117
|
+
}] end') || {
|
|
118
|
+
printf '%s\n' 'NOT_MEASURED: incomplete pull request response.' >&2
|
|
119
|
+
exit 2
|
|
120
|
+
}
|
|
121
|
+
printf '%s\n' "$prs" | node \
|
|
122
|
+
"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-node_modules/@codyswann/lisa/plugins/lisa}}/scripts/pr-arming-sweep.mjs"
|
|
92
123
|
```
|
|
93
124
|
|
|
125
|
+
`gh api --paginate --slurp` follows every REST page before the sweep starts;
|
|
126
|
+
there is no repository-wide row cap. Keep `auto_merge` present during conversion:
|
|
127
|
+
an absent field is an unread measurement, while explicit `null` means unarmed.
|
|
128
|
+
On any API or response validation failure, report `NOT_MEASURED` and stop this
|
|
129
|
+
read. Never report a partial queue as clean. The script path honors an exported
|
|
130
|
+
plugin root and otherwise resolves from the current project's installed Lisa
|
|
131
|
+
package; bare `plugins/lisa` paths are relative to Lisa's own checkout and do not
|
|
132
|
+
work from a consuming project.
|
|
133
|
+
|
|
94
134
|
Report the verdict verbatim, and never translate it into an absence:
|
|
95
135
|
|
|
96
136
|
- `MEASURED_CLEAN` — a **positive assertion**: N open PRs were read and every one is armed or deliberately unarmed. Say the count. "Nothing to report" is not the same sentence and must not be substituted for it.
|
|
@@ -211,11 +211,29 @@ if [ "$tool_name" != "Bash" ]; then
|
|
|
211
211
|
exit 0
|
|
212
212
|
;;
|
|
213
213
|
esac
|
|
214
|
-
|
|
214
|
+
# These terminal actions edit an existing item, not create the item.
|
|
215
|
+
# Strip only the measured action shape, then still inspect any preceding
|
|
216
|
+
# compound operation: create_issue_and_add_issue_comment remains a filing.
|
|
217
|
+
creation_name="$tool_name"
|
|
218
|
+
case "$creation_name" in
|
|
219
|
+
*[Aa]dd_[Ii]ssue_[Cc]omment)
|
|
220
|
+
creation_name="${creation_name%[Aa]dd_[Ii]ssue_[Cc]omment}" ;;
|
|
221
|
+
*[Aa]dd[Cc]omment[Tt]o[Jj]ira[Ii]ssue)
|
|
222
|
+
creation_name="${creation_name%[Aa]dd[Cc]omment[Tt]o[Jj]ira[Ii]ssue}" ;;
|
|
223
|
+
*[Aa]dd_[Cc]omment_[Tt]o_[Ii]ssue)
|
|
224
|
+
creation_name="${creation_name%[Aa]dd_[Cc]omment_[Tt]o_[Ii]ssue}" ;;
|
|
225
|
+
*[Aa]dd_[Ii]ssue_[Ll]abels)
|
|
226
|
+
creation_name="${creation_name%[Aa]dd_[Ii]ssue_[Ll]abels}" ;;
|
|
227
|
+
*[Aa]dd_[Ll]abels_[Tt]o_[Ii]ssue)
|
|
228
|
+
creation_name="${creation_name%[Aa]dd_[Ll]abels_[Tt]o_[Ii]ssue}" ;;
|
|
229
|
+
*[Aa]dd_[Rr]eaction_[Tt]o_[Ii]ssue_[Cc]omment)
|
|
230
|
+
creation_name="${creation_name%[Aa]dd_[Rr]eaction_[Tt]o_[Ii]ssue_[Cc]omment}" ;;
|
|
231
|
+
esac
|
|
232
|
+
case "$creation_name" in
|
|
215
233
|
*[Cc]reate* | *[Nn]ew* | *[Aa]dd* | *[Ff]ile*) ;;
|
|
216
234
|
*) exit 0 ;;
|
|
217
235
|
esac
|
|
218
|
-
case "$
|
|
236
|
+
case "$creation_name" in
|
|
219
237
|
*[Ii]ssue* | *[Tt]icket* | *[Tt]ask* | *[Ss]tory* | *[Bb]ug* | *[Ee]pic* | *[Ww]ork*) ;;
|
|
220
238
|
*) exit 0 ;;
|
|
221
239
|
esac
|
|
@@ -598,9 +598,25 @@ BACKSLASH = chr(92)
|
|
|
598
598
|
NEWLINE = chr(10)
|
|
599
599
|
|
|
600
600
|
STATEMENT_SEPARATORS = {";", "|", "||", "&", "&&", "(", ")", "&|"}
|
|
601
|
-
#
|
|
602
|
-
|
|
601
|
+
# Ordinary output, noclobber override, and combined stdout/stderr output.
|
|
602
|
+
# shlex separates fd prefixes (e.g. 10>>) from the operator automatically.
|
|
603
|
+
REDIRECT_OUT = {">", ">>", ">|", "&>", "&>>", ">&"}
|
|
603
604
|
REDIRECT_IN = "<"
|
|
605
|
+
SYNTAX_OPERATORS = STATEMENT_SEPARATORS | REDIRECT_OUT | {REDIRECT_IN}
|
|
606
|
+
# shlex groups adjacent punctuation even across distinct shell operators.
|
|
607
|
+
# Retain other shell operators intact so a here-string is not three `<` reads.
|
|
608
|
+
PUNCTUATION = re.compile(r"^[();<>|&]+$")
|
|
609
|
+
SHELL_OPERATOR = re.compile("|".join(
|
|
610
|
+
re.escape(operator) for operator in sorted(
|
|
611
|
+
SYNTAX_OPERATORS | {"<<<", "<<", "<&", "<>", ";;&", ";;", ";&", "|&"},
|
|
612
|
+
key=lambda operator: (-len(operator), operator),
|
|
613
|
+
)
|
|
614
|
+
))
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
class LiteralOperator(str):
|
|
618
|
+
"""Quoted or escaped operator text is an argument, never shell syntax."""
|
|
619
|
+
|
|
604
620
|
|
|
605
621
|
SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
|
|
606
622
|
SOURCE_BUILTINS = {"source", "."}
|
|
@@ -664,8 +680,30 @@ def normalise_lines(text):
|
|
|
664
680
|
Returns:
|
|
665
681
|
The same text with line breaks spelled as separators.
|
|
666
682
|
"""
|
|
667
|
-
|
|
668
|
-
|
|
683
|
+
output = []
|
|
684
|
+
quote = ""
|
|
685
|
+
position = 0
|
|
686
|
+
while position < len(text):
|
|
687
|
+
character = text[position]
|
|
688
|
+
if character == BACKSLASH and quote != "'" and position + 1 < len(text):
|
|
689
|
+
following = text[position + 1]
|
|
690
|
+
# The shell deletes continuations; inserting whitespace can split
|
|
691
|
+
# an operator or pathname and hide the actual output target.
|
|
692
|
+
if following != NEWLINE:
|
|
693
|
+
output.extend((character, following))
|
|
694
|
+
position += 2
|
|
695
|
+
continue
|
|
696
|
+
if quote:
|
|
697
|
+
if character == quote:
|
|
698
|
+
quote = ""
|
|
699
|
+
elif character in "'\"":
|
|
700
|
+
quote = character
|
|
701
|
+
if character == NEWLINE and not quote:
|
|
702
|
+
output.append(" ; ")
|
|
703
|
+
else:
|
|
704
|
+
output.append(character)
|
|
705
|
+
position += 1
|
|
706
|
+
return "".join(output)
|
|
669
707
|
|
|
670
708
|
|
|
671
709
|
def tokenize(text):
|
|
@@ -677,13 +715,30 @@ def tokenize(text):
|
|
|
677
715
|
Returns:
|
|
678
716
|
The token list, or None when the text does not lex.
|
|
679
717
|
"""
|
|
680
|
-
|
|
718
|
+
normalized = normalise_lines(text)
|
|
719
|
+
lexer = shlex.shlex(normalized, posix=True, punctuation_chars=True)
|
|
681
720
|
lexer.whitespace_split = True
|
|
682
721
|
# `#` is not a comment introducer on a typed command line, and letting shlex
|
|
683
722
|
# treat it as one silently truncates the rest of the line.
|
|
684
723
|
lexer.commenters = ""
|
|
685
724
|
try:
|
|
686
|
-
|
|
725
|
+
tokens = []
|
|
726
|
+
start = 0
|
|
727
|
+
for token in lexer:
|
|
728
|
+
# shlex strips quotes/escapes and reads one character ahead when
|
|
729
|
+
# emitting punctuation. Exclude its character pushback from the
|
|
730
|
+
# consumed source span so a quoted operator retains its provenance.
|
|
731
|
+
end = lexer.instream.tell() - len(lexer._pushback_chars)
|
|
732
|
+
raw = normalized[start:end].strip()
|
|
733
|
+
start = end
|
|
734
|
+
if PUNCTUATION.fullmatch(token):
|
|
735
|
+
if raw != token:
|
|
736
|
+
tokens.append(LiteralOperator(token))
|
|
737
|
+
else:
|
|
738
|
+
tokens.extend(SHELL_OPERATOR.findall(token))
|
|
739
|
+
else:
|
|
740
|
+
tokens.append(token)
|
|
741
|
+
return tokens
|
|
687
742
|
except ValueError:
|
|
688
743
|
return None
|
|
689
744
|
|
|
@@ -745,7 +800,7 @@ def statements(tokens):
|
|
|
745
800
|
grouped = []
|
|
746
801
|
current = []
|
|
747
802
|
for token in tokens:
|
|
748
|
-
if token in STATEMENT_SEPARATORS:
|
|
803
|
+
if not isinstance(token, LiteralOperator) and token in STATEMENT_SEPARATORS:
|
|
749
804
|
grouped.append(current)
|
|
750
805
|
current = []
|
|
751
806
|
continue
|
|
@@ -929,8 +984,14 @@ def write_targets(statement):
|
|
|
929
984
|
found = []
|
|
930
985
|
_token, program, args = command_word(statement)
|
|
931
986
|
for index, token in enumerate(statement):
|
|
932
|
-
if token in REDIRECT_OUT and index + 1 < len(statement):
|
|
933
|
-
|
|
987
|
+
if not isinstance(token, LiteralOperator) and token in REDIRECT_OUT and index + 1 < len(statement):
|
|
988
|
+
target = statement[index + 1]
|
|
989
|
+
# >&digit duplicates an fd, >&digit- moves it, and >&- closes it.
|
|
990
|
+
# Only the nonnumeric >&word form opens a pathname; &>1 still
|
|
991
|
+
# writes a file named 1 and must remain a write candidate.
|
|
992
|
+
if token == ">&" and re.fullmatch(r"(?:[0-9]+-?|-)", target):
|
|
993
|
+
continue
|
|
994
|
+
found.append(target)
|
|
934
995
|
if program == "tee":
|
|
935
996
|
found.extend(token for token in args if not token.startswith("-"))
|
|
936
997
|
elif program == "sed" and any(IN_PLACE.match(token) for token in args):
|
|
@@ -956,7 +1017,7 @@ def executed_script(statement):
|
|
|
956
1017
|
if program in SHELL_PROGRAMS:
|
|
957
1018
|
for index, token in enumerate(statement):
|
|
958
1019
|
# `bash < script.sh` runs that file as surely as `bash script.sh`.
|
|
959
|
-
if token == REDIRECT_IN and index + 1 < len(statement):
|
|
1020
|
+
if not isinstance(token, LiteralOperator) and token == REDIRECT_IN and index + 1 < len(statement):
|
|
960
1021
|
return statement[index + 1]
|
|
961
1022
|
return shell_script_operand(args)
|
|
962
1023
|
if program in SOURCE_BUILTINS:
|
|
@@ -582,10 +582,55 @@ sails through the arm gate and the latch goes on over the top of it.
|
|
|
582
582
|
**Read `reviewDecision` explicitly. Never infer it from a check count.**
|
|
583
583
|
|
|
584
584
|
```bash
|
|
585
|
-
gh
|
|
586
|
-
|
|
585
|
+
review_pages=$(gh api graphql --paginate --slurp \
|
|
586
|
+
-F owner=<owner> -F repo=<repo> -F pr=<pr> \
|
|
587
|
+
-f query='query($owner:String!,$repo:String!,$pr:Int!,$endCursor:String){
|
|
588
|
+
repository(owner:$owner,name:$repo){pullRequest(number:$pr){
|
|
589
|
+
state reviewDecision autoMergeRequest{enabledAt}
|
|
590
|
+
reviewThreads(first:100,after:$endCursor){
|
|
591
|
+
nodes{isResolved isOutdated}
|
|
592
|
+
pageInfo{hasNextPage endCursor}
|
|
593
|
+
}
|
|
594
|
+
}}
|
|
595
|
+
}') || {
|
|
596
|
+
printf '%s\n' 'Could not read PR review threads; merge readiness is unknown.' >&2
|
|
597
|
+
exit 1
|
|
598
|
+
}
|
|
599
|
+
printf '%s\n' "$review_pages" | jq -e '
|
|
600
|
+
def complete_page:
|
|
601
|
+
try (
|
|
602
|
+
((.errors // []) | length) == 0 and
|
|
603
|
+
(.data.repository.pullRequest | . as $pr |
|
|
604
|
+
has("state") and has("reviewDecision") and has("autoMergeRequest") and
|
|
605
|
+
(["OPEN", "CLOSED", "MERGED"] | index($pr.state)) != null and
|
|
606
|
+
([null, "APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"] |
|
|
607
|
+
index($pr.reviewDecision)) != null and
|
|
608
|
+
(.autoMergeRequest == null or
|
|
609
|
+
(.autoMergeRequest.enabledAt | type) == "string") and
|
|
610
|
+
(.reviewThreads.nodes | type) == "array" and
|
|
611
|
+
all(.reviewThreads.nodes[];
|
|
612
|
+
(.isResolved | type) == "boolean" and (.isOutdated | type) == "boolean") and
|
|
613
|
+
(.reviewThreads.pageInfo.hasNextPage | type) == "boolean")
|
|
614
|
+
) catch false;
|
|
615
|
+
if type != "array" then
|
|
616
|
+
error("Incomplete PR review response; merge readiness is unknown")
|
|
617
|
+
elif length == 0 or any(.[]; complete_page | not) or
|
|
618
|
+
.[-1].data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage != false
|
|
619
|
+
then error("Incomplete PR review response; merge readiness is unknown")
|
|
620
|
+
else {
|
|
621
|
+
state: .[-1].data.repository.pullRequest.state,
|
|
622
|
+
armed: (.[-1].data.repository.pullRequest.autoMergeRequest != null),
|
|
623
|
+
decision: .[-1].data.repository.pullRequest.reviewDecision,
|
|
624
|
+
unresolved: ([.[].data.repository.pullRequest.reviewThreads.nodes[] |
|
|
625
|
+
select(.isResolved == false)] | length)
|
|
626
|
+
} end'
|
|
587
627
|
```
|
|
588
628
|
|
|
629
|
+
`reviewThreads` is a GraphQL connection, not a supported `gh pr view --json`
|
|
630
|
+
field. Read every page, including outdated threads that remain unresolved.
|
|
631
|
+
If either the API request or response validation fails, stop with the diagnostic
|
|
632
|
+
above; a failed read must never become a count of zero unresolved threads.
|
|
633
|
+
|
|
589
634
|
`reviewDecision` is **not part of `statusCheckRollup`**. That is the whole reason
|
|
590
635
|
this stayed invisible: a failing-check count reads **zero** on a PR that can
|
|
591
636
|
never merge, and the checks tab is entirely green. Any rule keyed on "no red
|
|
@@ -1440,9 +1485,48 @@ states above, but any point where this run concludes it is finished, hands back,
|
|
|
1440
1485
|
or gives up. Re-read, from the live PR, immediately before reporting:
|
|
1441
1486
|
|
|
1442
1487
|
```bash
|
|
1443
|
-
gh
|
|
1444
|
-
|
|
1445
|
-
|
|
1488
|
+
review_pages=$(gh api graphql --paginate --slurp \
|
|
1489
|
+
-F owner=<owner> -F repo=<repo> -F pr=<pr> \
|
|
1490
|
+
-f query='query($owner:String!,$repo:String!,$pr:Int!,$endCursor:String){
|
|
1491
|
+
repository(owner:$owner,name:$repo){pullRequest(number:$pr){
|
|
1492
|
+
state reviewDecision autoMergeRequest{enabledAt}
|
|
1493
|
+
reviewThreads(first:100,after:$endCursor){
|
|
1494
|
+
nodes{isResolved isOutdated}
|
|
1495
|
+
pageInfo{hasNextPage endCursor}
|
|
1496
|
+
}
|
|
1497
|
+
}}
|
|
1498
|
+
}') || {
|
|
1499
|
+
printf '%s\n' 'Could not read PR review threads; merge readiness is unknown.' >&2
|
|
1500
|
+
exit 1
|
|
1501
|
+
}
|
|
1502
|
+
printf '%s\n' "$review_pages" | jq -e '
|
|
1503
|
+
def complete_page:
|
|
1504
|
+
try (
|
|
1505
|
+
((.errors // []) | length) == 0 and
|
|
1506
|
+
(.data.repository.pullRequest | . as $pr |
|
|
1507
|
+
has("state") and has("reviewDecision") and has("autoMergeRequest") and
|
|
1508
|
+
(["OPEN", "CLOSED", "MERGED"] | index($pr.state)) != null and
|
|
1509
|
+
([null, "APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"] |
|
|
1510
|
+
index($pr.reviewDecision)) != null and
|
|
1511
|
+
(.autoMergeRequest == null or
|
|
1512
|
+
(.autoMergeRequest.enabledAt | type) == "string") and
|
|
1513
|
+
(.reviewThreads.nodes | type) == "array" and
|
|
1514
|
+
all(.reviewThreads.nodes[];
|
|
1515
|
+
(.isResolved | type) == "boolean" and (.isOutdated | type) == "boolean") and
|
|
1516
|
+
(.reviewThreads.pageInfo.hasNextPage | type) == "boolean")
|
|
1517
|
+
) catch false;
|
|
1518
|
+
if type != "array" then
|
|
1519
|
+
error("Incomplete PR review response; merge readiness is unknown")
|
|
1520
|
+
elif length == 0 or any(.[]; complete_page | not) or
|
|
1521
|
+
.[-1].data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage != false
|
|
1522
|
+
then error("Incomplete PR review response; merge readiness is unknown")
|
|
1523
|
+
else {
|
|
1524
|
+
state: .[-1].data.repository.pullRequest.state,
|
|
1525
|
+
armed: (.[-1].data.repository.pullRequest.autoMergeRequest != null),
|
|
1526
|
+
decision: .[-1].data.repository.pullRequest.reviewDecision,
|
|
1527
|
+
unresolved: ([.[].data.repository.pullRequest.reviewThreads.nodes[] |
|
|
1528
|
+
select(.isResolved == false)] | length)
|
|
1529
|
+
} end'
|
|
1446
1530
|
```
|
|
1447
1531
|
|
|
1448
1532
|
If `state == OPEN` and `armed` and either `decision == "CHANGES_REQUESTED"` or
|
|
@@ -86,11 +86,51 @@ Alongside the two work queues, report the **arming state of the repo's open pull
|
|
|
86
86
|
Run the sweep rather than eyeballing the list — an LLM reading PR pages one at a time is exactly the shell-loop-by-prose failure #3512 records:
|
|
87
87
|
|
|
88
88
|
```bash
|
|
89
|
-
gh
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
repository=$(gh repo view --json nameWithOwner --jq .nameWithOwner) || {
|
|
90
|
+
printf '%s\n' 'NOT_MEASURED: could not resolve the current repository.' >&2
|
|
91
|
+
exit 2
|
|
92
|
+
}
|
|
93
|
+
pages=$(gh api --paginate --slurp \
|
|
94
|
+
"repos/$repository/pulls?state=open&per_page=100") || {
|
|
95
|
+
printf '%s\n' 'NOT_MEASURED: could not read every open pull request page.' >&2
|
|
96
|
+
exit 2
|
|
97
|
+
}
|
|
98
|
+
prs=$(printf '%s\n' "$pages" | jq -ce '
|
|
99
|
+
def complete_pr:
|
|
100
|
+
try (
|
|
101
|
+
has("auto_merge") and
|
|
102
|
+
(.auto_merge == null or
|
|
103
|
+
(.auto_merge.merge_method as $method | ["merge", "squash", "rebase"] |
|
|
104
|
+
index($method)) != null) and
|
|
105
|
+
(.number | type) == "number" and .number > 0 and
|
|
106
|
+
(.title | type) == "string" and (.html_url | type) == "string" and
|
|
107
|
+
(.draft | type) == "boolean" and has("body") and
|
|
108
|
+
(.body == null or (.body | type) == "string") and
|
|
109
|
+
(.labels | type) == "array" and all(.labels[]; (.name | type) == "string")
|
|
110
|
+
) catch false;
|
|
111
|
+
if type != "array" then error("Invalid PR page response")
|
|
112
|
+
elif length == 0 or any(.[]; type != "array") then error("Missing PR pages")
|
|
113
|
+
elif any(.[][]; complete_pr | not) then error("Incomplete PR metadata")
|
|
114
|
+
else [.[][] | {
|
|
115
|
+
number, title, url: .html_url, isDraft: .draft, labels, body,
|
|
116
|
+
autoMergeRequest: .auto_merge
|
|
117
|
+
}] end') || {
|
|
118
|
+
printf '%s\n' 'NOT_MEASURED: incomplete pull request response.' >&2
|
|
119
|
+
exit 2
|
|
120
|
+
}
|
|
121
|
+
printf '%s\n' "$prs" | node \
|
|
122
|
+
"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-node_modules/@codyswann/lisa/plugins/lisa}}/scripts/pr-arming-sweep.mjs"
|
|
92
123
|
```
|
|
93
124
|
|
|
125
|
+
`gh api --paginate --slurp` follows every REST page before the sweep starts;
|
|
126
|
+
there is no repository-wide row cap. Keep `auto_merge` present during conversion:
|
|
127
|
+
an absent field is an unread measurement, while explicit `null` means unarmed.
|
|
128
|
+
On any API or response validation failure, report `NOT_MEASURED` and stop this
|
|
129
|
+
read. Never report a partial queue as clean. The script path honors an exported
|
|
130
|
+
plugin root and otherwise resolves from the current project's installed Lisa
|
|
131
|
+
package; bare `plugins/lisa` paths are relative to Lisa's own checkout and do not
|
|
132
|
+
work from a consuming project.
|
|
133
|
+
|
|
94
134
|
Report the verdict verbatim, and never translate it into an absence:
|
|
95
135
|
|
|
96
136
|
- `MEASURED_CLEAN` — a **positive assertion**: N open PRs were read and every one is armed or deliberately unarmed. Say the count. "Nothing to report" is not the same sentence and must not be substituted for it.
|
|
@@ -211,11 +211,29 @@ if [ "$tool_name" != "Bash" ]; then
|
|
|
211
211
|
exit 0
|
|
212
212
|
;;
|
|
213
213
|
esac
|
|
214
|
-
|
|
214
|
+
# These terminal actions edit an existing item, not create the item.
|
|
215
|
+
# Strip only the measured action shape, then still inspect any preceding
|
|
216
|
+
# compound operation: create_issue_and_add_issue_comment remains a filing.
|
|
217
|
+
creation_name="$tool_name"
|
|
218
|
+
case "$creation_name" in
|
|
219
|
+
*[Aa]dd_[Ii]ssue_[Cc]omment)
|
|
220
|
+
creation_name="${creation_name%[Aa]dd_[Ii]ssue_[Cc]omment}" ;;
|
|
221
|
+
*[Aa]dd[Cc]omment[Tt]o[Jj]ira[Ii]ssue)
|
|
222
|
+
creation_name="${creation_name%[Aa]dd[Cc]omment[Tt]o[Jj]ira[Ii]ssue}" ;;
|
|
223
|
+
*[Aa]dd_[Cc]omment_[Tt]o_[Ii]ssue)
|
|
224
|
+
creation_name="${creation_name%[Aa]dd_[Cc]omment_[Tt]o_[Ii]ssue}" ;;
|
|
225
|
+
*[Aa]dd_[Ii]ssue_[Ll]abels)
|
|
226
|
+
creation_name="${creation_name%[Aa]dd_[Ii]ssue_[Ll]abels}" ;;
|
|
227
|
+
*[Aa]dd_[Ll]abels_[Tt]o_[Ii]ssue)
|
|
228
|
+
creation_name="${creation_name%[Aa]dd_[Ll]abels_[Tt]o_[Ii]ssue}" ;;
|
|
229
|
+
*[Aa]dd_[Rr]eaction_[Tt]o_[Ii]ssue_[Cc]omment)
|
|
230
|
+
creation_name="${creation_name%[Aa]dd_[Rr]eaction_[Tt]o_[Ii]ssue_[Cc]omment}" ;;
|
|
231
|
+
esac
|
|
232
|
+
case "$creation_name" in
|
|
215
233
|
*[Cc]reate* | *[Nn]ew* | *[Aa]dd* | *[Ff]ile*) ;;
|
|
216
234
|
*) exit 0 ;;
|
|
217
235
|
esac
|
|
218
|
-
case "$
|
|
236
|
+
case "$creation_name" in
|
|
219
237
|
*[Ii]ssue* | *[Tt]icket* | *[Tt]ask* | *[Ss]tory* | *[Bb]ug* | *[Ee]pic* | *[Ww]ork*) ;;
|
|
220
238
|
*) exit 0 ;;
|
|
221
239
|
esac
|
|
@@ -598,9 +598,25 @@ BACKSLASH = chr(92)
|
|
|
598
598
|
NEWLINE = chr(10)
|
|
599
599
|
|
|
600
600
|
STATEMENT_SEPARATORS = {";", "|", "||", "&", "&&", "(", ")", "&|"}
|
|
601
|
-
#
|
|
602
|
-
|
|
601
|
+
# Ordinary output, noclobber override, and combined stdout/stderr output.
|
|
602
|
+
# shlex separates fd prefixes (e.g. 10>>) from the operator automatically.
|
|
603
|
+
REDIRECT_OUT = {">", ">>", ">|", "&>", "&>>", ">&"}
|
|
603
604
|
REDIRECT_IN = "<"
|
|
605
|
+
SYNTAX_OPERATORS = STATEMENT_SEPARATORS | REDIRECT_OUT | {REDIRECT_IN}
|
|
606
|
+
# shlex groups adjacent punctuation even across distinct shell operators.
|
|
607
|
+
# Retain other shell operators intact so a here-string is not three `<` reads.
|
|
608
|
+
PUNCTUATION = re.compile(r"^[();<>|&]+$")
|
|
609
|
+
SHELL_OPERATOR = re.compile("|".join(
|
|
610
|
+
re.escape(operator) for operator in sorted(
|
|
611
|
+
SYNTAX_OPERATORS | {"<<<", "<<", "<&", "<>", ";;&", ";;", ";&", "|&"},
|
|
612
|
+
key=lambda operator: (-len(operator), operator),
|
|
613
|
+
)
|
|
614
|
+
))
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
class LiteralOperator(str):
|
|
618
|
+
"""Quoted or escaped operator text is an argument, never shell syntax."""
|
|
619
|
+
|
|
604
620
|
|
|
605
621
|
SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
|
|
606
622
|
SOURCE_BUILTINS = {"source", "."}
|
|
@@ -664,8 +680,30 @@ def normalise_lines(text):
|
|
|
664
680
|
Returns:
|
|
665
681
|
The same text with line breaks spelled as separators.
|
|
666
682
|
"""
|
|
667
|
-
|
|
668
|
-
|
|
683
|
+
output = []
|
|
684
|
+
quote = ""
|
|
685
|
+
position = 0
|
|
686
|
+
while position < len(text):
|
|
687
|
+
character = text[position]
|
|
688
|
+
if character == BACKSLASH and quote != "'" and position + 1 < len(text):
|
|
689
|
+
following = text[position + 1]
|
|
690
|
+
# The shell deletes continuations; inserting whitespace can split
|
|
691
|
+
# an operator or pathname and hide the actual output target.
|
|
692
|
+
if following != NEWLINE:
|
|
693
|
+
output.extend((character, following))
|
|
694
|
+
position += 2
|
|
695
|
+
continue
|
|
696
|
+
if quote:
|
|
697
|
+
if character == quote:
|
|
698
|
+
quote = ""
|
|
699
|
+
elif character in "'\"":
|
|
700
|
+
quote = character
|
|
701
|
+
if character == NEWLINE and not quote:
|
|
702
|
+
output.append(" ; ")
|
|
703
|
+
else:
|
|
704
|
+
output.append(character)
|
|
705
|
+
position += 1
|
|
706
|
+
return "".join(output)
|
|
669
707
|
|
|
670
708
|
|
|
671
709
|
def tokenize(text):
|
|
@@ -677,13 +715,30 @@ def tokenize(text):
|
|
|
677
715
|
Returns:
|
|
678
716
|
The token list, or None when the text does not lex.
|
|
679
717
|
"""
|
|
680
|
-
|
|
718
|
+
normalized = normalise_lines(text)
|
|
719
|
+
lexer = shlex.shlex(normalized, posix=True, punctuation_chars=True)
|
|
681
720
|
lexer.whitespace_split = True
|
|
682
721
|
# `#` is not a comment introducer on a typed command line, and letting shlex
|
|
683
722
|
# treat it as one silently truncates the rest of the line.
|
|
684
723
|
lexer.commenters = ""
|
|
685
724
|
try:
|
|
686
|
-
|
|
725
|
+
tokens = []
|
|
726
|
+
start = 0
|
|
727
|
+
for token in lexer:
|
|
728
|
+
# shlex strips quotes/escapes and reads one character ahead when
|
|
729
|
+
# emitting punctuation. Exclude its character pushback from the
|
|
730
|
+
# consumed source span so a quoted operator retains its provenance.
|
|
731
|
+
end = lexer.instream.tell() - len(lexer._pushback_chars)
|
|
732
|
+
raw = normalized[start:end].strip()
|
|
733
|
+
start = end
|
|
734
|
+
if PUNCTUATION.fullmatch(token):
|
|
735
|
+
if raw != token:
|
|
736
|
+
tokens.append(LiteralOperator(token))
|
|
737
|
+
else:
|
|
738
|
+
tokens.extend(SHELL_OPERATOR.findall(token))
|
|
739
|
+
else:
|
|
740
|
+
tokens.append(token)
|
|
741
|
+
return tokens
|
|
687
742
|
except ValueError:
|
|
688
743
|
return None
|
|
689
744
|
|
|
@@ -745,7 +800,7 @@ def statements(tokens):
|
|
|
745
800
|
grouped = []
|
|
746
801
|
current = []
|
|
747
802
|
for token in tokens:
|
|
748
|
-
if token in STATEMENT_SEPARATORS:
|
|
803
|
+
if not isinstance(token, LiteralOperator) and token in STATEMENT_SEPARATORS:
|
|
749
804
|
grouped.append(current)
|
|
750
805
|
current = []
|
|
751
806
|
continue
|
|
@@ -929,8 +984,14 @@ def write_targets(statement):
|
|
|
929
984
|
found = []
|
|
930
985
|
_token, program, args = command_word(statement)
|
|
931
986
|
for index, token in enumerate(statement):
|
|
932
|
-
if token in REDIRECT_OUT and index + 1 < len(statement):
|
|
933
|
-
|
|
987
|
+
if not isinstance(token, LiteralOperator) and token in REDIRECT_OUT and index + 1 < len(statement):
|
|
988
|
+
target = statement[index + 1]
|
|
989
|
+
# >&digit duplicates an fd, >&digit- moves it, and >&- closes it.
|
|
990
|
+
# Only the nonnumeric >&word form opens a pathname; &>1 still
|
|
991
|
+
# writes a file named 1 and must remain a write candidate.
|
|
992
|
+
if token == ">&" and re.fullmatch(r"(?:[0-9]+-?|-)", target):
|
|
993
|
+
continue
|
|
994
|
+
found.append(target)
|
|
934
995
|
if program == "tee":
|
|
935
996
|
found.extend(token for token in args if not token.startswith("-"))
|
|
936
997
|
elif program == "sed" and any(IN_PLACE.match(token) for token in args):
|
|
@@ -956,7 +1017,7 @@ def executed_script(statement):
|
|
|
956
1017
|
if program in SHELL_PROGRAMS:
|
|
957
1018
|
for index, token in enumerate(statement):
|
|
958
1019
|
# `bash < script.sh` runs that file as surely as `bash script.sh`.
|
|
959
|
-
if token == REDIRECT_IN and index + 1 < len(statement):
|
|
1020
|
+
if not isinstance(token, LiteralOperator) and token == REDIRECT_IN and index + 1 < len(statement):
|
|
960
1021
|
return statement[index + 1]
|
|
961
1022
|
return shell_script_operand(args)
|
|
962
1023
|
if program in SOURCE_BUILTINS:
|