@blogic-cz/agent-tools 0.8.14 → 0.8.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.8.14",
3
+ "version": "0.8.16",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -112,8 +112,10 @@
112
112
  "check:ci": "bun check.ts ci",
113
113
  "format": "oxfmt",
114
114
  "format:check": "oxfmt --check",
115
+ "gh-tool": "bun src/gh-tool/index.ts",
115
116
  "lint": "oxlint -c ./.oxlintrc.json --deny-warnings",
116
117
  "lint:fix": "oxlint -c ./.oxlintrc.json --fix",
118
+ "session-tool": "bun src/session-tool/index.ts",
117
119
  "update:skills": "bun run .agents/skills/update-packages/references/skills-update-local.ts",
118
120
  "test": "vitest run"
119
121
  },
@@ -2,6 +2,7 @@ import { Command, Flag } from "effect/unstable/cli";
2
2
  import { Effect, Option } from "effect";
3
3
 
4
4
  import { formatOption, logFormatted } from "#shared";
5
+ import { resolveOptionalTextInput, resolveRequiredTextInput } from "#gh/text-input";
5
6
 
6
7
  import {
7
8
  closeIssue,
@@ -91,6 +92,10 @@ export const issueCloseCommand = Command.make(
91
92
  Flag.withDescription("Comment to add when closing"),
92
93
  Flag.optional,
93
94
  ),
95
+ commentFile: Flag.string("comment-file").pipe(
96
+ Flag.withDescription("Read close comment from a file path or '-' for stdin"),
97
+ Flag.optional,
98
+ ),
94
99
  format: formatOption,
95
100
  issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number to close")),
96
101
  reason: Flag.choice("reason", ["completed", "not planned"]).pipe(
@@ -98,10 +103,19 @@ export const issueCloseCommand = Command.make(
98
103
  Flag.withDefault("completed"),
99
104
  ),
100
105
  },
101
- ({ comment, format, issue, reason }) =>
106
+ ({ comment, commentFile, format, issue, reason }) =>
102
107
  Effect.gen(function* () {
108
+ const resolvedComment = yield* resolveOptionalTextInput(
109
+ "gh-tool issue close",
110
+ Option.getOrNull(comment),
111
+ Option.getOrNull(commentFile),
112
+ "--comment",
113
+ "--comment-file",
114
+ "comment",
115
+ );
116
+
103
117
  const result = yield* closeIssue({
104
- comment: Option.getOrNull(comment),
118
+ comment: resolvedComment,
105
119
  issue,
106
120
  reason,
107
121
  });
@@ -116,13 +130,26 @@ export const issueReopenCommand = Command.make(
116
130
  Flag.withDescription("Comment to add when reopening"),
117
131
  Flag.optional,
118
132
  ),
133
+ commentFile: Flag.string("comment-file").pipe(
134
+ Flag.withDescription("Read reopen comment from a file path or '-' for stdin"),
135
+ Flag.optional,
136
+ ),
119
137
  format: formatOption,
120
138
  issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number to reopen")),
121
139
  },
122
- ({ comment, format, issue }) =>
140
+ ({ comment, commentFile, format, issue }) =>
123
141
  Effect.gen(function* () {
142
+ const resolvedComment = yield* resolveOptionalTextInput(
143
+ "gh-tool issue reopen",
144
+ Option.getOrNull(comment),
145
+ Option.getOrNull(commentFile),
146
+ "--comment",
147
+ "--comment-file",
148
+ "comment",
149
+ );
150
+
124
151
  const result = yield* reopenIssue({
125
- comment: Option.getOrNull(comment),
152
+ comment: resolvedComment,
126
153
  issue,
127
154
  });
128
155
  yield* logFormatted(result, format);
@@ -132,13 +159,26 @@ export const issueReopenCommand = Command.make(
132
159
  export const issueCommentCommand = Command.make(
133
160
  "comment",
134
161
  {
135
- body: Flag.string("body").pipe(Flag.withDescription("Comment body text")),
162
+ body: Flag.string("body").pipe(Flag.withDescription("Comment body text"), Flag.optional),
163
+ bodyFile: Flag.string("body-file").pipe(
164
+ Flag.withDescription("Read comment body from a file path or '-' for stdin"),
165
+ Flag.optional,
166
+ ),
136
167
  format: formatOption,
137
168
  issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number to comment on")),
138
169
  },
139
- ({ body, format, issue }) =>
170
+ ({ body, bodyFile, format, issue }) =>
140
171
  Effect.gen(function* () {
141
- const result = yield* commentOnIssue({ body, issue });
172
+ const resolvedBody = yield* resolveRequiredTextInput(
173
+ "gh-tool issue comment",
174
+ Option.getOrNull(body),
175
+ Option.getOrNull(bodyFile),
176
+ "--body",
177
+ "--body-file",
178
+ "body",
179
+ );
180
+
181
+ const result = yield* commentOnIssue({ body: resolvedBody, issue });
142
182
  yield* logFormatted(result, format);
143
183
  }),
144
184
  ).pipe(Command.withDescription("Post a comment on an issue"));
@@ -155,6 +195,10 @@ export const issueEditCommand = Command.make(
155
195
  Flag.optional,
156
196
  ),
157
197
  body: Flag.string("body").pipe(Flag.withDescription("New issue body"), Flag.optional),
198
+ bodyFile: Flag.string("body-file").pipe(
199
+ Flag.withDescription("Read issue body from a file path or '-' for stdin"),
200
+ Flag.optional,
201
+ ),
158
202
  format: formatOption,
159
203
  issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number to edit")),
160
204
  removeAssignee: Flag.string("remove-assignee").pipe(
@@ -167,12 +211,31 @@ export const issueEditCommand = Command.make(
167
211
  ),
168
212
  title: Flag.string("title").pipe(Flag.withDescription("New issue title"), Flag.optional),
169
213
  },
170
- ({ addAssignee, addLabels, body, format, issue, removeAssignee, removeLabels, title }) =>
214
+ ({
215
+ addAssignee,
216
+ addLabels,
217
+ body,
218
+ bodyFile,
219
+ format,
220
+ issue,
221
+ removeAssignee,
222
+ removeLabels,
223
+ title,
224
+ }) =>
171
225
  Effect.gen(function* () {
226
+ const resolvedBody = yield* resolveOptionalTextInput(
227
+ "gh-tool issue edit",
228
+ Option.getOrNull(body),
229
+ Option.getOrNull(bodyFile),
230
+ "--body",
231
+ "--body-file",
232
+ "body",
233
+ );
234
+
172
235
  const result = yield* editIssue({
173
236
  addAssignee: Option.getOrNull(addAssignee),
174
237
  addLabels: Option.getOrNull(addLabels),
175
- body: Option.getOrNull(body),
238
+ body: resolvedBody,
176
239
  issue,
177
240
  removeAssignee: Option.getOrNull(removeAssignee),
178
241
  removeLabels: Option.getOrNull(removeLabels),
@@ -4,6 +4,11 @@ import { Effect, Option } from "effect";
4
4
  import type { PRStatusResult } from "#gh/types";
5
5
 
6
6
  import { formatOption, logFormatted } from "#shared";
7
+ import {
8
+ resolveDefaultTextInput,
9
+ resolveOptionalTextInput,
10
+ resolveRequiredTextInput,
11
+ } from "#gh/text-input";
7
12
  import {
8
13
  CI_CHECK_WATCH_TIMEOUT_MS,
9
14
  DEFAULT_DELETE_BRANCH,
@@ -16,6 +21,7 @@ import {
16
21
  detectPRStatus,
17
22
  editPR,
18
23
  fetchChecks,
24
+ fetchChecksForCommand,
19
25
  fetchFailedChecks,
20
26
  mergePR,
21
27
  rerunChecks,
@@ -70,9 +76,10 @@ export const prCreateCommand = Command.make(
70
76
  Flag.withDescription("Base branch for the PR"),
71
77
  Flag.withDefault("test"),
72
78
  ),
73
- body: Flag.string("body").pipe(
74
- Flag.withDescription("PR body/description"),
75
- Flag.withDefault(""),
79
+ body: Flag.string("body").pipe(Flag.withDescription("PR body/description"), Flag.optional),
80
+ bodyFile: Flag.string("body-file").pipe(
81
+ Flag.withDescription("Read PR body from a file path or '-' for stdin"),
82
+ Flag.optional,
76
83
  ),
77
84
  draft: Flag.boolean("draft").pipe(
78
85
  Flag.withDescription("Create as draft PR"),
@@ -85,11 +92,21 @@ export const prCreateCommand = Command.make(
85
92
  ),
86
93
  title: Flag.string("title").pipe(Flag.withDescription("PR title")),
87
94
  },
88
- ({ base, body, draft, format, head, title }) =>
95
+ ({ base, body, bodyFile, draft, format, head, title }) =>
89
96
  Effect.gen(function* () {
97
+ const resolvedBody = yield* resolveDefaultTextInput(
98
+ "gh-tool pr create",
99
+ Option.getOrNull(body),
100
+ Option.getOrNull(bodyFile),
101
+ "--body",
102
+ "--body-file",
103
+ "body",
104
+ "",
105
+ );
106
+
90
107
  const info = yield* createPR({
91
108
  base,
92
- body,
109
+ body: resolvedBody,
93
110
  draft,
94
111
  head: Option.getOrNull(head),
95
112
  title,
@@ -102,16 +119,29 @@ export const prEditCommand = Command.make(
102
119
  "edit",
103
120
  {
104
121
  body: Flag.string("body").pipe(Flag.withDescription("New PR body/description"), Flag.optional),
122
+ bodyFile: Flag.string("body-file").pipe(
123
+ Flag.withDescription("Read PR body from a file path or '-' for stdin"),
124
+ Flag.optional,
125
+ ),
105
126
  format: formatOption,
106
127
  pr: Flag.integer("pr").pipe(Flag.withDescription("PR number to edit")),
107
128
  title: Flag.string("title").pipe(Flag.withDescription("New PR title"), Flag.optional),
108
129
  },
109
- ({ body, format, pr, title }) =>
130
+ ({ body, bodyFile, format, pr, title }) =>
110
131
  Effect.gen(function* () {
132
+ const resolvedBody = yield* resolveOptionalTextInput(
133
+ "gh-tool pr edit",
134
+ Option.getOrNull(body),
135
+ Option.getOrNull(bodyFile),
136
+ "--body",
137
+ "--body-file",
138
+ "body",
139
+ );
140
+
111
141
  const info = yield* editPR({
112
142
  pr,
113
143
  title: Option.getOrNull(title),
114
- body: Option.getOrNull(body),
144
+ body: resolvedBody,
115
145
  });
116
146
  yield* logFormatted(info, format);
117
147
  }),
@@ -171,7 +201,7 @@ export const prChecksCommand = Command.make(
171
201
  ({ failFast, format, pr, timeout, watch }) =>
172
202
  Effect.gen(function* () {
173
203
  const prNumber = Option.getOrNull(pr);
174
- const checks = yield* fetchChecks(prNumber, watch, failFast, timeout);
204
+ const checks = yield* fetchChecksForCommand(prNumber, watch, failFast, timeout);
175
205
  yield* logFormatted(checks, format);
176
206
  }),
177
207
  ).pipe(Command.withDescription("Fetch CI check status for a PR (optionally watch with timeout)"));
@@ -338,17 +368,32 @@ export const prIssueCommentsLatestCommand = Command.make(
338
368
  export const prCommentCommand = Command.make(
339
369
  "comment",
340
370
  {
341
- body: Flag.string("body").pipe(Flag.withDescription("General PR comment body text")),
371
+ body: Flag.string("body").pipe(
372
+ Flag.withDescription("General PR comment body text"),
373
+ Flag.optional,
374
+ ),
375
+ bodyFile: Flag.string("body-file").pipe(
376
+ Flag.withDescription("Read general PR comment body from a file path or '-' for stdin"),
377
+ Flag.optional,
378
+ ),
342
379
  format: formatOption,
343
380
  pr: Flag.integer("pr").pipe(
344
381
  Flag.withDescription("PR number (default: current branch PR)"),
345
382
  Flag.optional,
346
383
  ),
347
384
  },
348
- ({ body, format, pr }) =>
385
+ ({ body, bodyFile, format, pr }) =>
349
386
  Effect.gen(function* () {
350
387
  const prNumber = Option.getOrNull(pr);
351
- const result = yield* postIssueComment(prNumber, body);
388
+ const resolvedBody = yield* resolveRequiredTextInput(
389
+ "gh-tool pr comment",
390
+ Option.getOrNull(body),
391
+ Option.getOrNull(bodyFile),
392
+ "--body",
393
+ "--body-file",
394
+ "body",
395
+ );
396
+ const result = yield* postIssueComment(prNumber, resolvedBody);
352
397
  yield* logFormatted(result, format);
353
398
  }),
354
399
  ).pipe(Command.withDescription("Post a general PR discussion comment"));
@@ -375,7 +420,11 @@ export const prDiscussionSummaryCommand = Command.make(
375
420
  export const prReplyCommand = Command.make(
376
421
  "reply",
377
422
  {
378
- body: Flag.string("body").pipe(Flag.withDescription("Reply body text")),
423
+ body: Flag.string("body").pipe(Flag.withDescription("Reply body text"), Flag.optional),
424
+ bodyFile: Flag.string("body-file").pipe(
425
+ Flag.withDescription("Read reply body from a file path or '-' for stdin"),
426
+ Flag.optional,
427
+ ),
379
428
  commentId: Flag.integer("comment-id").pipe(
380
429
  Flag.withDescription("ID of the comment to reply to"),
381
430
  ),
@@ -385,10 +434,18 @@ export const prReplyCommand = Command.make(
385
434
  Flag.optional,
386
435
  ),
387
436
  },
388
- ({ body, commentId, format, pr }) =>
437
+ ({ body, bodyFile, commentId, format, pr }) =>
389
438
  Effect.gen(function* () {
390
439
  const prNumber = Option.getOrNull(pr);
391
- const result = yield* replyToComment(prNumber, commentId, body);
440
+ const resolvedBody = yield* resolveRequiredTextInput(
441
+ "gh-tool pr reply",
442
+ Option.getOrNull(body),
443
+ Option.getOrNull(bodyFile),
444
+ "--body",
445
+ "--body-file",
446
+ "body",
447
+ );
448
+ const result = yield* replyToComment(prNumber, commentId, resolvedBody);
392
449
  yield* logFormatted(result, format);
393
450
  }),
394
451
  ).pipe(Command.withDescription("Reply to an inline review comment"));
@@ -415,6 +472,10 @@ export const prSubmitReviewCommand = Command.make(
415
472
  Flag.withDescription("Optional review body text when submitting"),
416
473
  Flag.optional,
417
474
  ),
475
+ bodyFile: Flag.string("body-file").pipe(
476
+ Flag.withDescription("Read review body from a file path or '-' for stdin"),
477
+ Flag.optional,
478
+ ),
418
479
  format: formatOption,
419
480
  pr: Flag.integer("pr").pipe(
420
481
  Flag.withDescription("PR number (default: current branch PR)"),
@@ -427,11 +488,18 @@ export const prSubmitReviewCommand = Command.make(
427
488
  Flag.optional,
428
489
  ),
429
490
  },
430
- ({ body, format, pr, reviewId }) =>
491
+ ({ body, bodyFile, format, pr, reviewId }) =>
431
492
  Effect.gen(function* () {
432
493
  const prNumber = Option.getOrNull(pr);
433
494
  const reviewIdValue = Option.getOrNull(reviewId);
434
- const bodyValue = Option.getOrNull(body);
495
+ const bodyValue = yield* resolveOptionalTextInput(
496
+ "gh-tool pr submit-review",
497
+ Option.getOrNull(body),
498
+ Option.getOrNull(bodyFile),
499
+ "--body",
500
+ "--body-file",
501
+ "body",
502
+ );
435
503
  const result = yield* submitPendingReview(prNumber, reviewIdValue, bodyValue);
436
504
  yield* logFormatted(result, format);
437
505
  }),
@@ -471,7 +539,11 @@ export const prReviewTriageCommand = Command.make(
471
539
  export const prReplyAndResolveCommand = Command.make(
472
540
  "reply-and-resolve",
473
541
  {
474
- body: Flag.string("body").pipe(Flag.withDescription("Reply body text")),
542
+ body: Flag.string("body").pipe(Flag.withDescription("Reply body text"), Flag.optional),
543
+ bodyFile: Flag.string("body-file").pipe(
544
+ Flag.withDescription("Read reply body from a file path or '-' for stdin"),
545
+ Flag.optional,
546
+ ),
475
547
  commentId: Flag.integer("comment-id").pipe(
476
548
  Flag.withDescription("ID of the comment to reply to"),
477
549
  ),
@@ -484,10 +556,18 @@ export const prReplyAndResolveCommand = Command.make(
484
556
  Flag.withDescription("GraphQL node ID of the thread to resolve"),
485
557
  ),
486
558
  },
487
- ({ body, commentId, format, pr, threadId }) =>
559
+ ({ body, bodyFile, commentId, format, pr, threadId }) =>
488
560
  Effect.gen(function* () {
489
561
  const prNumber = Option.getOrNull(pr);
490
- const replyResult = yield* replyToComment(prNumber, commentId, body);
562
+ const resolvedBody = yield* resolveRequiredTextInput(
563
+ "gh-tool pr reply-and-resolve",
564
+ Option.getOrNull(body),
565
+ Option.getOrNull(bodyFile),
566
+ "--body",
567
+ "--body-file",
568
+ "body",
569
+ );
570
+ const replyResult = yield* replyToComment(prNumber, commentId, resolvedBody);
491
571
  const resolveResult = yield* resolveThread(threadId);
492
572
  yield* logFormatted({ reply: replyResult, resolve: resolveResult }, format);
493
573
  }),
@@ -1,6 +1,15 @@
1
1
  import { Console, Effect, Option } from "effect";
2
2
 
3
- import type { BranchPRDetail, CheckResult, MergeResult, MergeStrategy, PRInfo } from "#gh/types";
3
+ import type {
4
+ BranchPRDetail,
5
+ CheckResult,
6
+ FailedCheckDetail,
7
+ FailedCheckRunContext,
8
+ MergeResult,
9
+ MergeStrategy,
10
+ PRInfo,
11
+ WorkflowRunDetail,
12
+ } from "#gh/types";
4
13
 
5
14
  import { GitHubCommandError, GitHubMergeError, GitHubTimeoutError } from "#gh/errors";
6
15
  import { GitHubService } from "#gh/service";
@@ -8,6 +17,174 @@ import { GitHubService } from "#gh/service";
8
17
  import type { ButStatusJson, PRViewJsonResult } from "./helpers";
9
18
  import { runLocalCommand } from "./helpers";
10
19
 
20
+ const CHECK_JSON_FIELDS = "name,state,bucket,link";
21
+ const GITHUB_ACTIONS_RUN_ID_RE = /github\.com\/[^/]+\/[^/]+\/actions\/runs\/(\d+)/;
22
+
23
+ const buildChecksCommand = (pr: number | null, includeWatch: boolean): string =>
24
+ `bun agent-tools-gh pr checks${pr !== null ? ` --pr ${pr}` : ""}${includeWatch ? " --watch" : ""}`;
25
+
26
+ const buildChecksFailedCommand = (pr: number | null): string =>
27
+ `bun agent-tools-gh pr checks-failed${pr !== null ? ` --pr ${pr}` : ""}`;
28
+
29
+ const extractRunIdFromCheckLink = (link: string): number | null => {
30
+ const match = link.match(GITHUB_ACTIONS_RUN_ID_RE);
31
+ if (!match?.[1]) {
32
+ return null;
33
+ }
34
+
35
+ const runId = Number(match[1]);
36
+ return Number.isFinite(runId) ? runId : null;
37
+ };
38
+
39
+ const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureContext")(function* (
40
+ runId: number,
41
+ ) {
42
+ const gh = yield* GitHubService;
43
+
44
+ const run = yield* gh
45
+ .runGhJson<WorkflowRunDetail>([
46
+ "run",
47
+ "view",
48
+ String(runId),
49
+ "--json",
50
+ "databaseId,url,workflowName,status,conclusion,jobs",
51
+ ])
52
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
53
+
54
+ if (run === null) {
55
+ return null;
56
+ }
57
+
58
+ const failedJobs = run.jobs
59
+ .filter((job) => job.conclusion === "failure" || job.status === "failure")
60
+ .map((job) => ({
61
+ name: job.name,
62
+ status: job.status,
63
+ conclusion: job.conclusion,
64
+ url: job.url,
65
+ failedSteps: job.steps
66
+ .filter((step) => step.conclusion === "failure" || step.status === "failure")
67
+ .map((step) => step.name),
68
+ }));
69
+
70
+ const context: FailedCheckRunContext = {
71
+ runId: run.databaseId,
72
+ url: run.url,
73
+ workflowName: run.workflowName,
74
+ status: run.status,
75
+ conclusion: run.conclusion,
76
+ failedJobs,
77
+ };
78
+
79
+ return context;
80
+ });
81
+
82
+ const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (pr: number | null) {
83
+ const gh = yield* GitHubService;
84
+
85
+ const args = ["pr", "checks"];
86
+ if (pr !== null) {
87
+ args.push(String(pr));
88
+ }
89
+
90
+ return yield* gh.runGhJson<CheckResult[]>([...args, "--json", CHECK_JSON_FIELDS]);
91
+ });
92
+
93
+ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function* (
94
+ pr: number | null,
95
+ checks: CheckResult[],
96
+ ) {
97
+ const failedChecks = checks.filter((check) => check.bucket === "fail");
98
+ const pendingChecks = checks.filter((check) => check.bucket === "pending");
99
+ const passedChecks = checks.filter((check) => check.bucket === "pass");
100
+
101
+ const runIds = [
102
+ ...new Set(
103
+ failedChecks
104
+ .map((check) => extractRunIdFromCheckLink(check.link))
105
+ .filter((id) => id !== null),
106
+ ),
107
+ ];
108
+
109
+ const runContexts = new Map<number, FailedCheckRunContext | null>();
110
+ const contexts = yield* Effect.forEach(
111
+ runIds,
112
+ (runId) =>
113
+ fetchWorkflowRunFailureContext(runId).pipe(
114
+ Effect.map((context) => [runId, context] as const),
115
+ ),
116
+ { concurrency: "unbounded" },
117
+ );
118
+
119
+ for (const [runId, context] of contexts) {
120
+ runContexts.set(runId, context);
121
+ }
122
+
123
+ const enrichedFailedChecks: FailedCheckDetail[] = failedChecks.map((check) => {
124
+ const runId = extractRunIdFromCheckLink(check.link);
125
+ return {
126
+ ...check,
127
+ runId,
128
+ run: runId === null ? null : (runContexts.get(runId) ?? null),
129
+ };
130
+ });
131
+
132
+ const nextCommands = [
133
+ buildChecksFailedCommand(pr),
134
+ ...new Set(
135
+ enrichedFailedChecks.flatMap((check) => {
136
+ if (check.runId === null) {
137
+ return [];
138
+ }
139
+
140
+ const commands = [`bun agent-tools-gh workflow view --run ${check.runId}`];
141
+ const firstFailedJob = check.run?.failedJobs[0];
142
+ if (firstFailedJob) {
143
+ commands.push(
144
+ `bun agent-tools-gh workflow job-logs --run ${check.runId} --job ${JSON.stringify(firstFailedJob.name)} --failed-steps-only`,
145
+ );
146
+ }
147
+
148
+ return commands;
149
+ }),
150
+ ),
151
+ ...(pendingChecks.length > 0 ? [buildChecksCommand(pr, true)] : []),
152
+ ];
153
+
154
+ const message =
155
+ failedChecks.length === 0
156
+ ? pendingChecks.length > 0
157
+ ? `No failed checks yet; ${pendingChecks.length} check(s) are still running.`
158
+ : "No failed checks detected."
159
+ : pendingChecks.length > 0
160
+ ? `Detected ${failedChecks.length} failed check(s) while ${pendingChecks.length} check(s) are still running.`
161
+ : `Detected ${failedChecks.length} failed check(s).`;
162
+
163
+ const hint =
164
+ failedChecks.length === 0
165
+ ? pendingChecks.length > 0
166
+ ? "Wait for the remaining checks to finish, or use --watch to block until CI settles."
167
+ : "All current checks are green."
168
+ : pendingChecks.length > 0
169
+ ? "Inspect the failed workflow run first. Other checks are still running and may change overall merge readiness."
170
+ : "Inspect the failed workflow run and failed job logs to get the first concrete error, then rerun only if the failure is understood.";
171
+
172
+ return {
173
+ status: failedChecks.length > 0 ? "failed" : "no_failures",
174
+ message,
175
+ summary: {
176
+ total: checks.length,
177
+ failed: failedChecks.length,
178
+ pending: pendingChecks.length,
179
+ passed: passedChecks.length,
180
+ },
181
+ failedChecks: enrichedFailedChecks,
182
+ pendingChecks,
183
+ hint,
184
+ nextCommands,
185
+ };
186
+ });
187
+
11
188
  export const viewPR = Effect.fn("pr.viewPR")(function* (prNumber: number | null) {
12
189
  const gh = yield* GitHubService;
13
190
 
@@ -446,22 +623,50 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
446
623
  }),
447
624
  );
448
625
 
449
- return yield* gh.runGhJson<CheckResult[]>([...args, "--json", "name,state,bucket,link"]);
626
+ return yield* fetchCheckResults(pr);
450
627
  }
451
628
 
452
- const results = yield* gh.runGhJson<CheckResult[]>([...args, "--json", "name,state,bucket,link"]);
629
+ const results = yield* fetchCheckResults(pr);
453
630
  if (results.some((c) => c.bucket === "pending")) {
454
631
  yield* Console.warn(
455
632
  `ℹ️ Some checks are still running. Prefer --watch to block until completion instead of polling:\n` +
456
- ` bun agent-tools-gh pr checks${pr !== null ? ` --pr ${pr}` : ""} --watch`,
633
+ ` ${buildChecksCommand(pr, true)}`,
457
634
  );
458
635
  }
459
636
  return results;
460
637
  });
461
638
 
462
639
  export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (pr: number | null) {
463
- const checks = yield* fetchChecks(pr, false, false, 0);
464
- return checks.filter((check) => check.bucket === "fail");
640
+ const checks = yield* fetchCheckResults(pr);
641
+ return yield* buildFailedChecksReport(pr, checks);
642
+ });
643
+
644
+ export const fetchChecksForCommand = Effect.fn("pr.fetchChecksForCommand")(function* (
645
+ pr: number | null,
646
+ watch: boolean,
647
+ failFast: boolean,
648
+ timeoutSeconds: number,
649
+ ) {
650
+ if (!watch) {
651
+ return yield* fetchChecks(pr, false, failFast, timeoutSeconds);
652
+ }
653
+
654
+ const watchedChecks = yield* fetchChecks(pr, true, failFast, timeoutSeconds).pipe(
655
+ Effect.catchTag("GitHubCommandError", (error) =>
656
+ Effect.succeed({ _tag: "command_error" as const, error }),
657
+ ),
658
+ );
659
+
660
+ if (Array.isArray(watchedChecks)) {
661
+ return watchedChecks;
662
+ }
663
+
664
+ const finalChecks = yield* fetchCheckResults(pr);
665
+ if (finalChecks.some((check) => check.bucket === "fail")) {
666
+ return yield* buildFailedChecksReport(pr, finalChecks);
667
+ }
668
+
669
+ return yield* Effect.fail(watchedChecks.error);
465
670
  });
466
671
 
467
672
  export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
@@ -2,6 +2,7 @@ import { Command, Flag } from "effect/unstable/cli";
2
2
  import { Effect, Option } from "effect";
3
3
 
4
4
  import { formatOption, logFormatted } from "#shared";
5
+ import { resolveOptionalTextInput } from "#gh/text-input";
5
6
  import { GitHubService } from "./service";
6
7
 
7
8
  type ReleaseListItem = {
@@ -343,6 +344,10 @@ export const releaseCreateCommand = Command.make(
343
344
  Flag.withDescription("Release notes body (markdown)"),
344
345
  Flag.optional,
345
346
  ),
347
+ bodyFile: Flag.string("body-file").pipe(
348
+ Flag.withDescription("Read release notes body from a file path or '-' for stdin"),
349
+ Flag.optional,
350
+ ),
346
351
  draft: Flag.boolean("draft").pipe(
347
352
  Flag.withDescription("Create as draft release"),
348
353
  Flag.withDefault(false),
@@ -388,6 +393,7 @@ export const releaseCreateCommand = Command.make(
388
393
  },
389
394
  ({
390
395
  body,
396
+ bodyFile,
391
397
  draft,
392
398
  format,
393
399
  generateNotes,
@@ -402,10 +408,19 @@ export const releaseCreateCommand = Command.make(
402
408
  verifyTag,
403
409
  }) =>
404
410
  Effect.gen(function* () {
411
+ const resolvedBody = yield* resolveOptionalTextInput(
412
+ "gh-tool release create",
413
+ Option.getOrNull(body),
414
+ Option.getOrNull(bodyFile),
415
+ "--body",
416
+ "--body-file",
417
+ "body",
418
+ );
419
+
405
420
  const result = yield* createRelease({
406
421
  tag,
407
422
  title: Option.getOrNull(title),
408
- body: Option.getOrNull(body),
423
+ body: resolvedBody,
409
424
  notesFile: Option.getOrNull(notesFile),
410
425
  draft,
411
426
  prerelease,
@@ -477,6 +492,10 @@ export const releaseEditCommand = Command.make(
477
492
  Flag.withDescription("New release notes body (markdown)"),
478
493
  Flag.optional,
479
494
  ),
495
+ bodyFile: Flag.string("body-file").pipe(
496
+ Flag.withDescription("Read release notes body from a file path or '-' for stdin"),
497
+ Flag.optional,
498
+ ),
480
499
  draft: Flag.boolean("draft").pipe(
481
500
  Flag.withDescription("Set draft status (true/false). Omit to keep current value"),
482
501
  Flag.optional,
@@ -497,12 +516,21 @@ export const releaseEditCommand = Command.make(
497
516
  tag: Flag.string("tag").pipe(Flag.withDescription("Release tag to edit (e.g., v1.2.3)")),
498
517
  title: Flag.string("title").pipe(Flag.withDescription("New release title"), Flag.optional),
499
518
  },
500
- ({ body, draft, format, latest, prerelease, repo, tag, title }) =>
519
+ ({ body, bodyFile, draft, format, latest, prerelease, repo, tag, title }) =>
501
520
  Effect.gen(function* () {
521
+ const resolvedBody = yield* resolveOptionalTextInput(
522
+ "gh-tool release edit",
523
+ Option.getOrNull(body),
524
+ Option.getOrNull(bodyFile),
525
+ "--body",
526
+ "--body-file",
527
+ "body",
528
+ );
529
+
502
530
  const edited = yield* editRelease({
503
531
  tag,
504
532
  title: Option.getOrNull(title),
505
- body: Option.getOrNull(body),
533
+ body: resolvedBody,
506
534
  draft: Option.getOrNull(draft),
507
535
  prerelease: Option.getOrNull(prerelease),
508
536
  latest: Option.getOrNull(latest),
@@ -0,0 +1,146 @@
1
+ import { Effect, Schema } from "effect";
2
+
3
+ import { GitHubCommandError } from "#gh/errors";
4
+
5
+ const STDIN_SENTINEL = "-";
6
+ const SENSITIVE_PATH_PATTERNS = [/\.env(\..+)?$/, /\.envrc$/, /\.(pem|key|p12|pfx|cer|crt)$/i];
7
+ const MissingMode = Schema.Literals(["error", "null", "default"]);
8
+
9
+ const readTextFromStdin = () => Bun.stdin.text();
10
+
11
+ const readTextFile = (filePath: string) => {
12
+ if (SENSITIVE_PATH_PATTERNS.some((pattern) => pattern.test(filePath))) {
13
+ return Promise.reject(new Error(`Refusing to read sensitive file: ${filePath}`));
14
+ }
15
+
16
+ return Bun.file(filePath).text();
17
+ };
18
+
19
+ const ensureResolvedText = (resolvedValue: string | null, context: string) => {
20
+ if (resolvedValue === null) {
21
+ throw new Error(`Invariant violation: ${context} resolved to null`);
22
+ }
23
+
24
+ return resolvedValue;
25
+ };
26
+
27
+ type ResolveTextInputOptions = {
28
+ command: string;
29
+ value: string | null;
30
+ fileValue: string | null;
31
+ valueFlag: string;
32
+ fileFlag: string;
33
+ missingMode: Schema.Schema.Type<typeof MissingMode>;
34
+ missingValue?: string;
35
+ label: string;
36
+ };
37
+
38
+ const resolveTextInputInternal = Effect.fn("gh.resolveTextInputInternal")(function* (
39
+ options: ResolveTextInputOptions,
40
+ ) {
41
+ const { command, fileFlag, fileValue, label, missingMode, missingValue, value, valueFlag } =
42
+ options;
43
+
44
+ if (value !== null && fileValue !== null) {
45
+ return yield* Effect.fail(
46
+ new GitHubCommandError({
47
+ command,
48
+ exitCode: 1,
49
+ stderr: `Provide exactly one of ${valueFlag} or ${fileFlag}`,
50
+ message: `Provide exactly one of ${valueFlag} or ${fileFlag}`,
51
+ }),
52
+ );
53
+ }
54
+
55
+ if (value !== null) {
56
+ return value;
57
+ }
58
+
59
+ if (fileValue !== null) {
60
+ const source = fileValue === STDIN_SENTINEL ? "stdin" : fileValue;
61
+
62
+ return yield* Effect.tryPromise({
63
+ try: () => (fileValue === STDIN_SENTINEL ? readTextFromStdin() : readTextFile(fileValue)),
64
+ catch: (error) =>
65
+ new GitHubCommandError({
66
+ command,
67
+ exitCode: 1,
68
+ stderr: `Failed to read ${label} from ${source}: ${error instanceof Error ? error.message : String(error)}`,
69
+ message: `Failed to read ${label} from ${source}: ${error instanceof Error ? error.message : String(error)}`,
70
+ }),
71
+ });
72
+ }
73
+
74
+ if (missingMode === "null") {
75
+ return null;
76
+ }
77
+
78
+ if (missingMode === "default") {
79
+ return missingValue ?? "";
80
+ }
81
+
82
+ return yield* Effect.fail(
83
+ new GitHubCommandError({
84
+ command,
85
+ exitCode: 1,
86
+ stderr: `Missing ${label}. Provide ${valueFlag} or ${fileFlag}`,
87
+ message: `Missing ${label}. Provide ${valueFlag} or ${fileFlag}`,
88
+ }),
89
+ );
90
+ });
91
+
92
+ export const resolveRequiredTextInput = (
93
+ command: string,
94
+ value: string | null,
95
+ fileValue: string | null,
96
+ valueFlag: string,
97
+ fileFlag: string,
98
+ label: string,
99
+ ): Effect.Effect<string, GitHubCommandError> =>
100
+ resolveTextInputInternal({
101
+ command,
102
+ value,
103
+ fileValue,
104
+ valueFlag,
105
+ fileFlag,
106
+ missingMode: "error",
107
+ label,
108
+ }).pipe(Effect.map((resolvedValue) => ensureResolvedText(resolvedValue, "required text input")));
109
+
110
+ export const resolveOptionalTextInput = (
111
+ command: string,
112
+ value: string | null,
113
+ fileValue: string | null,
114
+ valueFlag: string,
115
+ fileFlag: string,
116
+ label: string,
117
+ ): Effect.Effect<string | null, GitHubCommandError> =>
118
+ resolveTextInputInternal({
119
+ command,
120
+ value,
121
+ fileValue,
122
+ valueFlag,
123
+ fileFlag,
124
+ missingMode: "null",
125
+ label,
126
+ });
127
+
128
+ export const resolveDefaultTextInput = (
129
+ command: string,
130
+ value: string | null,
131
+ fileValue: string | null,
132
+ valueFlag: string,
133
+ fileFlag: string,
134
+ label: string,
135
+ defaultValue: string,
136
+ ): Effect.Effect<string, GitHubCommandError> =>
137
+ resolveTextInputInternal({
138
+ command,
139
+ value,
140
+ fileValue,
141
+ valueFlag,
142
+ fileFlag,
143
+ missingMode: "default",
144
+ missingValue: defaultValue,
145
+ label,
146
+ }).pipe(Effect.map((resolvedValue) => ensureResolvedText(resolvedValue, "default text input")));
@@ -62,6 +62,62 @@ export type CheckResult = {
62
62
  link: string;
63
63
  };
64
64
 
65
+ export type FailedCheckJob = {
66
+ name: string;
67
+ status: string;
68
+ conclusion: string | null;
69
+ url: string;
70
+ failedSteps: string[];
71
+ };
72
+
73
+ export type FailedCheckRunContext = {
74
+ runId: number;
75
+ url: string | null;
76
+ workflowName: string | null;
77
+ status: string;
78
+ conclusion: string | null;
79
+ failedJobs: FailedCheckJob[];
80
+ };
81
+
82
+ export type FailedCheckDetail = CheckResult & {
83
+ runId: number | null;
84
+ run: FailedCheckRunContext | null;
85
+ };
86
+
87
+ export type FailedChecksReport = {
88
+ status: "failed" | "no_failures";
89
+ message: string;
90
+ summary: {
91
+ total: number;
92
+ failed: number;
93
+ pending: number;
94
+ passed: number;
95
+ };
96
+ failedChecks: FailedCheckDetail[];
97
+ pendingChecks: CheckResult[];
98
+ hint: string;
99
+ nextCommands: string[];
100
+ };
101
+
102
+ export type WorkflowRunDetail = {
103
+ databaseId: number;
104
+ url: string;
105
+ workflowName: string | null;
106
+ status: string;
107
+ conclusion: string | null;
108
+ jobs: Array<{
109
+ name: string;
110
+ status: string;
111
+ conclusion: string | null;
112
+ url: string;
113
+ steps: Array<{
114
+ name: string;
115
+ status: string;
116
+ conclusion: string | null;
117
+ }>;
118
+ }>;
119
+ };
120
+
65
121
  export type MergeResult = {
66
122
  merged: boolean;
67
123
  strategy: MergeStrategy;