@blogic-cz/agent-tools 1.6.17 → 1.6.19

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.
@@ -0,0 +1,159 @@
1
+ /** Closed admission grammar, not an evaluator. Unknown syntax never acquires a proof. */
2
+ export function isPassiveJqFilter(filter: string): boolean {
3
+ if (filter.length > 65_536) return false;
4
+ if (new TextEncoder().encode(filter).byteLength > 65_536) return false;
5
+ const tokens: string[] = [];
6
+ const lexer =
7
+ /\s+|"(?:[^"\\\x00-\x1f]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*"|(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|[A-Za-z_][A-Za-z0-9_]*|\/\/|==|!=|<=|>=|[.{}[\]():,?|+*/%<>-]/y; // eslint-disable-line eslint/no-control-regex -- JSON strings exclude literal control bytes.
8
+ let offset = 0;
9
+ while (offset < filter.length) {
10
+ lexer.lastIndex = offset;
11
+ const match = lexer.exec(filter);
12
+ if (!match) return false;
13
+ offset = lexer.lastIndex;
14
+ if (!/^\s/.test(match[0])) tokens.push(match[0]);
15
+ if (tokens.length > 4096) return false;
16
+ }
17
+ let position = 0;
18
+ const take = (token: string): boolean => {
19
+ if (tokens[position] !== token) return false;
20
+ position++;
21
+ return true;
22
+ };
23
+ const identifier = (token: string | undefined): boolean =>
24
+ /^[A-Za-z_][A-Za-z0-9_]*$/.test(token ?? "");
25
+ const string = (token: string | undefined): boolean => token?.startsWith('"') === true;
26
+ const number = (token: string | undefined): boolean => /^[0-9]/.test(token ?? "");
27
+ const noArgs = new Set([
28
+ "length",
29
+ "keys",
30
+ "keys_unsorted",
31
+ "type",
32
+ "values",
33
+ "empty",
34
+ "add",
35
+ "min",
36
+ "max",
37
+ "sort",
38
+ "unique",
39
+ "flatten",
40
+ "not",
41
+ "reverse",
42
+ "to_entries",
43
+ "from_entries",
44
+ ]);
45
+ const oneArg = new Set([
46
+ "map",
47
+ "map_values",
48
+ "select",
49
+ "contains",
50
+ "has",
51
+ "sort_by",
52
+ "group_by",
53
+ "unique_by",
54
+ "any",
55
+ "all",
56
+ "join",
57
+ ]);
58
+ const operators = new Set([
59
+ "|",
60
+ "//",
61
+ "==",
62
+ "!=",
63
+ "<",
64
+ ">",
65
+ "<=",
66
+ ">=",
67
+ "+",
68
+ "-",
69
+ "*",
70
+ "/",
71
+ "%",
72
+ "and",
73
+ "or",
74
+ ]);
75
+ const expression = (depth: number, commas = false): boolean => {
76
+ if (depth > 64 || !term(depth)) return false;
77
+ const nextOperator = () =>
78
+ operators.has(tokens[position] ?? "") || (commas && tokens[position] === ",");
79
+ while (nextOperator()) {
80
+ position++;
81
+ if (!term(depth)) return false;
82
+ }
83
+ return true;
84
+ };
85
+ const term = (depth: number): boolean => {
86
+ if (take("-")) return number(tokens[position]) && (position++, true);
87
+ if (take(".")) {
88
+ if (identifier(tokens[position]) || string(tokens[position])) position++;
89
+ } else if (take("(")) {
90
+ if (!expression(depth + 1, true) || !take(")")) return false;
91
+ } else if (take("[")) {
92
+ if (!take("]") && (!expression(depth + 1, true) || !take("]"))) return false;
93
+ } else if (take("{")) {
94
+ if (!take("}")) {
95
+ do {
96
+ const key = tokens[position];
97
+ if (!identifier(key) && !string(key)) return false;
98
+ position++;
99
+ if (take(":")) {
100
+ if (!expression(depth + 1)) return false;
101
+ } else if (!identifier(key)) return false;
102
+ } while (take(","));
103
+ if (!take("}")) return false;
104
+ }
105
+ } else {
106
+ const token = tokens[position];
107
+ if (string(token) || number(token) || ["true", "false", "null"].includes(token ?? ""))
108
+ position++;
109
+ else if (noArgs.has(token ?? "")) position++;
110
+ else if (oneArg.has(token ?? "")) {
111
+ position++;
112
+ if (!take("(") || !expression(depth + 1) || !take(")")) return false;
113
+ } else return false;
114
+ }
115
+ while (true) {
116
+ if (take("?")) continue;
117
+ if (take(".")) {
118
+ if (!identifier(tokens[position]) && !string(tokens[position])) return false;
119
+ position++;
120
+ } else if (take("[")) {
121
+ if (!take("]")) {
122
+ if (take("-")) {
123
+ if (!number(tokens[position])) return false;
124
+ position++;
125
+ } else if (string(tokens[position]) || number(tokens[position])) position++;
126
+ else return false;
127
+ if (!take("]")) return false;
128
+ }
129
+ } else break;
130
+ }
131
+ return true;
132
+ };
133
+ return expression(0, true) && position === tokens.length;
134
+ }
135
+
136
+ /** Only known formatting/input flags and one literal program, followed by file operands. */
137
+ export function passiveJqProgramIndex(argv: string[]): number | undefined {
138
+ if (argv[0]?.split("/").at(-1) !== "jq") return undefined;
139
+ const longOptions = new Set([
140
+ "--raw-output",
141
+ "--raw-input",
142
+ "--compact-output",
143
+ "--slurp",
144
+ "--monochrome-output",
145
+ "--color-output",
146
+ "--null-input",
147
+ "--exit-status",
148
+ "--ascii-output",
149
+ "--join-output",
150
+ "--sort-keys",
151
+ "--unbuffered",
152
+ "--tab",
153
+ ]);
154
+ let index = 1;
155
+ while (/^-[rRcMsScneaj]+$/.test(argv[index] ?? "") || longOptions.has(argv[index] ?? "")) index++;
156
+ if (argv[index] === "--") index++;
157
+ if (argv.slice(index + 1).some((arg) => arg.startsWith("-") && arg !== "-")) return undefined;
158
+ return isPassiveJqFilter(argv[index] ?? "") ? index : undefined;
159
+ }
@@ -747,6 +747,10 @@ export const prMergeCommand = Command.make(
747
747
  Flag.withDefault(DEFAULT_DELETE_BRANCH),
748
748
  ),
749
749
  format: formatOption,
750
+ matchHeadCommit: Flag.String("match-head-commit").pipe(
751
+ Flag.withDescription("Require the PR head to match this full reviewed commit SHA"),
752
+ Flag.optional,
753
+ ),
750
754
  pr: Flag.Int("pr").pipe(Flag.withDescription("PR number to merge")),
751
755
  repo: repoOption,
752
756
  strategy: Flag.Literals("strategy", MERGE_STRATEGIES).pipe(
@@ -754,13 +758,14 @@ export const prMergeCommand = Command.make(
754
758
  Flag.withDefault(DEFAULT_MERGE_STRATEGY),
755
759
  ),
756
760
  },
757
- ({ confirm, deleteBranch, format, pr, repo, strategy }) =>
761
+ ({ confirm, deleteBranch, format, matchHeadCommit, pr, repo, strategy }) =>
758
762
  withRepo(
759
763
  repo,
760
764
  Effect.gen(function* () {
761
765
  const result = yield* mergePR({
762
766
  confirm,
763
767
  deleteBranch,
768
+ matchHeadCommit: Option.getOrUndefined(matchHeadCommit),
764
769
  pr,
765
770
  strategy,
766
771
  });
@@ -43,6 +43,8 @@ const STABLE_SNAPSHOT_ATTEMPTS = 3;
43
43
  const GITHUB_ACTIONS_RUN_ID_RE = /github\.com\/[^/]+\/[^/]+\/actions\/runs\/(\d+)/;
44
44
  const MERGE_SHA_POLL_INTERVAL_MS = 1000;
45
45
  const MERGE_SHA_WAIT_SECONDS = 5;
46
+ const isFullCommitSha = (value: unknown): value is string =>
47
+ typeof value === "string" && value.length === 40 && /^[0-9a-f]+$/i.test(value);
46
48
 
47
49
  const validatePRTitle = Effect.fn("pr.validatePRTitle")(function* (title: string) {
48
50
  const gh = yield* GitHubService;
@@ -930,16 +932,54 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
930
932
  strategy: MergeStrategy;
931
933
  deleteBranch: boolean;
932
934
  confirm: boolean;
935
+ matchHeadCommit?: string;
933
936
  }) {
934
937
  const gh = yield* GitHubService;
938
+ const matchHeadCommit = opts.matchHeadCommit;
935
939
 
936
- const info = yield* gh.runGhJson<PRViewJsonResult>([
937
- "pr",
938
- "view",
939
- String(opts.pr),
940
- "--json",
941
- "number,url,title,headRefName,baseRefName,state,isDraft,mergeable",
942
- ]);
940
+ if (matchHeadCommit !== undefined && !isFullCommitSha(matchHeadCommit)) {
941
+ return yield* new GitHubMergeError({
942
+ message: "--match-head-commit must be a full 40-character hexadecimal commit SHA",
943
+ reason: "unknown",
944
+ hint: "Provide the complete reviewed head commit SHA before retrying.",
945
+ nextCommand: `agent-tools-gh pr view --pr ${opts.pr}`,
946
+ });
947
+ }
948
+
949
+ const info = yield* gh
950
+ .runGhJson<PRViewJsonResult & { headRefOid?: unknown }>([
951
+ "pr",
952
+ "view",
953
+ String(opts.pr),
954
+ "--json",
955
+ "number,url,title,headRefName,baseRefName,state,isDraft,mergeable" +
956
+ (matchHeadCommit !== undefined ? ",headRefOid" : ""),
957
+ ])
958
+ .pipe(
959
+ Effect.mapError((error) =>
960
+ matchHeadCommit === undefined
961
+ ? error
962
+ : new GitHubMergeError({
963
+ message: `Could not verify the head commit of PR #${opts.pr}: ${error.message}`,
964
+ reason: "unknown",
965
+ hint: "The merge is refused while the head is unknown. Re-read the PR and verify its reviewed head before retrying.",
966
+ nextCommand: `agent-tools-gh pr view --pr ${opts.pr}`,
967
+ }),
968
+ ),
969
+ );
970
+
971
+ if (
972
+ matchHeadCommit !== undefined &&
973
+ (!isFullCommitSha(info?.headRefOid) ||
974
+ info.headRefOid.toLowerCase() !== matchHeadCommit.toLowerCase())
975
+ ) {
976
+ return yield* new GitHubMergeError({
977
+ message: `PR #${opts.pr} head does not match the reviewed commit ${matchHeadCommit}`,
978
+ reason: "unknown",
979
+ hint: "The head is missing, invalid, or changed. Re-read the PR and review its current head before retrying with that SHA.",
980
+ nextCommand: `agent-tools-gh pr view --pr ${opts.pr}`,
981
+ });
982
+ }
943
983
 
944
984
  const repo = opts.deleteBranch ? yield* gh.getRepoInfo() : null;
945
985
 
@@ -1120,6 +1160,9 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
1120
1160
  });
1121
1161
 
1122
1162
  const mergeArgs = ["pr", "merge", String(opts.pr), `--${opts.strategy}`];
1163
+ if (matchHeadCommit !== undefined) {
1164
+ mergeArgs.push("--match-head-commit", matchHeadCommit);
1165
+ }
1123
1166
 
1124
1167
  const shaFromAsyncMerge = yield* gh
1125
1168
  .runGh(mergeArgs)
@@ -1127,7 +1170,16 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
1127
1170
  Effect.map(() => null),
1128
1171
  Effect.catchTag("GitHubCommandError", (error) =>
1129
1172
  ASYNC_MERGE_REQUIRED_RE.test(error.stderr)
1130
- ? mergeViaAsyncApi({ pr: opts.pr, strategy: opts.strategy })
1173
+ ? matchHeadCommit !== undefined
1174
+ ? Effect.fail(
1175
+ new GitHubMergeError({
1176
+ message: `PR #${opts.pr} requires an asynchronous merge that cannot enforce --match-head-commit`,
1177
+ reason: "unknown",
1178
+ hint: "The asynchronous fallback was refused because its exact-head condition is unsupported. Keep the reviewed SHA requirement and use a merge path that enforces it after reviewing the current head.",
1179
+ nextCommand: `agent-tools-gh pr view --pr ${opts.pr}`,
1180
+ }),
1181
+ )
1182
+ : mergeViaAsyncApi({ pr: opts.pr, strategy: opts.strategy })
1131
1183
  : Effect.fail(error),
1132
1184
  ),
1133
1185
  )