@blogic-cz/agent-tools 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,27 +15,104 @@ These tools wrap each CLI with:
15
15
 
16
16
  ## Installation
17
17
 
18
- > **Recommended:** Copy the repo URL and tell your AI agent to install it. The agent will set up everything — dependencies, config file, credential guard — in the right places for your project.
18
+ > **Recommended:** Copy the repo URL and tell your AI agent to install it. The agent will set up everything — dev dependency, config file, credential guard, skill — in the right places for your project.
19
19
  >
20
20
  > ```
21
- > Install @blogic-cz/agent-tools from https://github.com/blogic-cz/agent-tools and set it up for this project.
21
+ > Install @blogic-cz/agent-tools from https://github.com/blogic-cz/agent-tools as a dev dependency, install the agent-tools skill, set up the credential guard, and configure the tools I need for this project.
22
22
  > ```
23
23
 
24
24
  ### Manual installation
25
25
 
26
26
  ```bash
27
- bun add @blogic-cz/agent-tools
27
+ bun add -d @blogic-cz/agent-tools
28
28
  ```
29
29
 
30
30
  **Requirements:** [Bun](https://bun.sh/) >=1.0.0
31
31
 
32
32
  ### For agents reading this
33
33
 
34
- Clone the repo and look at the source. The tools are in `src/`, each tool is self-contained:
34
+ Follow these steps to set up agent-tools in the consumer project.
35
+
36
+ **Step 1 — Install**
37
+
38
+ - **JavaScript/TypeScript project** (has `package.json`): Install as a **dev dependency**:
39
+ ```bash
40
+ bun add -d @blogic-cz/agent-tools
41
+ ```
42
+ - **Non-JavaScript project** (no `package.json`): Skip installation — run tools directly with `bunx`:
43
+ ```bash
44
+ bunx agent-tools-gh pr status
45
+ bunx agent-tools-k8s pods --env test
46
+ ```
47
+
48
+ **Step 2 — Configure**
49
+
50
+ Ask the user which tools they need (gh, db, k8s, az, logs, session). Create `agent-tools.json5` in the project root with **only the selected tools configured**. See [`examples/agent-tools.json5`](./examples/agent-tools.json5) for the full config reference with all options documented.
51
+
52
+ Minimal starting config:
53
+
54
+ ```json5
55
+ {
56
+ $schema: "https://raw.githubusercontent.com/blogic-cz/agent-tools/main/schemas/agent-tools.schema.json",
57
+ }
58
+ ```
59
+
60
+ **Step 3 — Credential Guard**
61
+
62
+ Set up the credential guard for the agent's platform:
63
+
64
+ - **Claude Code**: Add the `PreToolUse` hook to `.claude/settings.json` — see [Setup for Claude Code](#setup-for-claude-code)
65
+ - **OpenCode**: Create the plugin at `.opencode/plugins/credential-guard.ts` — see [Setup for OpenCode](#setup-for-opencode)
66
+
67
+ **Step 4 — Test & Demo**
68
+
69
+ For each tool the user selected in Step 2:
70
+
71
+ 1. Run `bunx agent-tools-<tool> --help` to verify it works
72
+ 2. Summarize the key commands available
73
+ 3. Show the user what data they have access to based on their `agent-tools.json5` — e.g. which environments, clusters, namespaces, databases, or profiles are configured and reachable
74
+
75
+ **Step 5 — Skill & Agent Docs**
76
+
77
+ Tell the user to run the following command themselves — it's interactive and lets them choose which agents to install the skill for:
35
78
 
36
79
  ```bash
37
- git clone https://github.com/blogic-cz/agent-tools.git
38
- ls src/ # gh-tool/ db-tool/ k8s-tool/ az-tool/ logs-tool/ session-tool/ credential-guard/
80
+ npx skills add blogic-cz/agent-tools
81
+ ```
82
+
83
+ **Do not run this command for the user** — it requires interactive selection.
84
+
85
+ Then update the project's `AGENTS.md` and/or `CLAUDE.md`:
86
+
87
+ 1. Add an `agent-tools` row to the skills table (if one exists):
88
+ ```markdown
89
+ | Agent wrapper tools (`db-tool`, `k8s-tool`, `logs-tool`, `az-tool`, `gh` patterns) | `agent-tools` |
90
+ ```
91
+ 2. Add or update the **Tooling** section:
92
+
93
+ ```markdown
94
+ ## Tooling
95
+
96
+ For tool wrappers and operational patterns, load `agent-tools`.
97
+ ```
98
+
99
+ **Step 6 — Custom Tool Scaffold**
100
+
101
+ Create an `agent-tools/` directory in the project root with an example tool so the user has a working template for building project-specific tools. Copy the scaffold from [`examples/custom-tool/`](./examples/custom-tool/):
102
+
103
+ ```
104
+ agent-tools/
105
+ package.json # private package depending on @blogic-cz/agent-tools
106
+ tsconfig.json # extends root tsconfig
107
+ noop.ts # placeholder export for typecheck
108
+ example-tool/
109
+ index.ts # ping-pong example using Effect CLI
110
+ ```
111
+
112
+ After creating the files, run `bun install` in the `agent-tools/` directory (or from the workspace root if it's a monorepo). Then verify:
113
+
114
+ ```bash
115
+ bun run agent-tools/example-tool/index.ts ping
39
116
  ```
40
117
 
41
118
  ## Quick Start
@@ -75,6 +152,22 @@ bunx agent-tools-gh pr review-triage # interactive summary of PR feedback
75
152
  bunx agent-tools-k8s pods --env test # list pods (structured command)
76
153
  ```
77
154
 
155
+ Optionally, add script aliases to your `package.json` for shorter invocation:
156
+
157
+ ```json
158
+ {
159
+ "scripts": {
160
+ "gh-tool": "agent-tools-gh",
161
+ "k8s-tool": "agent-tools-k8s",
162
+ "db-tool": "agent-tools-db",
163
+ "logs-tool": "agent-tools-logs",
164
+ "session-tool": "agent-tools-session"
165
+ }
166
+ }
167
+ ```
168
+
169
+ Then run via `bun run k8s-tool -- pods --env test` instead of `bunx agent-tools-k8s pods --env test`.
170
+
78
171
  4. Hook up the credential guard in your agent config (Claude Code, OpenCode, etc.):
79
172
 
80
173
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, and sessions",
5
5
  "keywords": [
6
6
  "agent",
@@ -5,12 +5,13 @@ import { Effect, Layer } from "effect";
5
5
 
6
6
  import { renderCauseToStderr, VERSION } from "#shared";
7
7
  import {
8
- issueListCommand,
9
- issueViewCommand,
10
8
  issueCloseCommand,
11
- issueReopenCommand,
12
9
  issueCommentCommand,
13
10
  issueEditCommand,
11
+ issueListCommand,
12
+ issueReopenCommand,
13
+ issueTriageSummaryCommand,
14
+ issueViewCommand,
14
15
  } from "./issue";
15
16
  import {
16
17
  prViewCommand,
@@ -72,7 +73,9 @@ const prCommand = Command.make("pr", {}).pipe(
72
73
  );
73
74
 
74
75
  const issueCommand = Command.make("issue", {}).pipe(
75
- Command.withDescription("Issue operations (list, view, close, reopen, comment, edit)"),
76
+ Command.withDescription(
77
+ "Issue operations (list, view, close, reopen, comment, edit, triage-summary)",
78
+ ),
76
79
  Command.withSubcommands([
77
80
  issueListCommand,
78
81
  issueViewCommand,
@@ -80,6 +83,7 @@ const issueCommand = Command.make("issue", {}).pipe(
80
83
  issueReopenCommand,
81
84
  issueCommentCommand,
82
85
  issueEditCommand,
86
+ issueTriageSummaryCommand,
83
87
  ]),
84
88
  );
85
89
 
@@ -0,0 +1,145 @@
1
+ import { Command, Flag } from "effect/unstable/cli";
2
+ import { Effect, Option } from "effect";
3
+
4
+ import { formatOption, logFormatted } from "#shared";
5
+
6
+ import { closeIssue, commentOnIssue, editIssue, listIssues, reopenIssue, viewIssue } from "./core";
7
+
8
+ export const issueListCommand = Command.make(
9
+ "list",
10
+ {
11
+ format: formatOption,
12
+ labels: Flag.string("labels").pipe(
13
+ Flag.withDescription("Filter by label (comma-separated)"),
14
+ Flag.optional,
15
+ ),
16
+ limit: Flag.integer("limit").pipe(
17
+ Flag.withDescription("Maximum number of issues to return"),
18
+ Flag.withDefault(30),
19
+ ),
20
+ state: Flag.choice("state", ["open", "closed", "all"]).pipe(
21
+ Flag.withDescription("Filter by state: open, closed, all"),
22
+ Flag.withDefault("open"),
23
+ ),
24
+ },
25
+ ({ format, labels, limit, state }) =>
26
+ Effect.gen(function* () {
27
+ const issues = yield* listIssues({
28
+ labels: Option.getOrNull(labels),
29
+ limit,
30
+ state,
31
+ });
32
+ yield* logFormatted(issues, format);
33
+ }),
34
+ ).pipe(Command.withDescription("List issues (default: open, use --state to filter)"));
35
+
36
+ export const issueViewCommand = Command.make(
37
+ "view",
38
+ {
39
+ format: formatOption,
40
+ issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number")),
41
+ },
42
+ ({ format, issue }) =>
43
+ Effect.gen(function* () {
44
+ const info = yield* viewIssue(issue);
45
+ yield* logFormatted(info, format);
46
+ }),
47
+ ).pipe(Command.withDescription("View issue details"));
48
+
49
+ export const issueCloseCommand = Command.make(
50
+ "close",
51
+ {
52
+ comment: Flag.string("comment").pipe(
53
+ Flag.withDescription("Comment to add when closing"),
54
+ Flag.optional,
55
+ ),
56
+ format: formatOption,
57
+ issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number to close")),
58
+ reason: Flag.choice("reason", ["completed", "not planned"]).pipe(
59
+ Flag.withDescription("Close reason: completed, not planned"),
60
+ Flag.withDefault("completed"),
61
+ ),
62
+ },
63
+ ({ comment, format, issue, reason }) =>
64
+ Effect.gen(function* () {
65
+ const result = yield* closeIssue({
66
+ comment: Option.getOrNull(comment),
67
+ issue,
68
+ reason,
69
+ });
70
+ yield* logFormatted(result, format);
71
+ }),
72
+ ).pipe(Command.withDescription("Close an issue with optional comment and reason"));
73
+
74
+ export const issueReopenCommand = Command.make(
75
+ "reopen",
76
+ {
77
+ comment: Flag.string("comment").pipe(
78
+ Flag.withDescription("Comment to add when reopening"),
79
+ Flag.optional,
80
+ ),
81
+ format: formatOption,
82
+ issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number to reopen")),
83
+ },
84
+ ({ comment, format, issue }) =>
85
+ Effect.gen(function* () {
86
+ const result = yield* reopenIssue({
87
+ comment: Option.getOrNull(comment),
88
+ issue,
89
+ });
90
+ yield* logFormatted(result, format);
91
+ }),
92
+ ).pipe(Command.withDescription("Reopen a closed issue"));
93
+
94
+ export const issueCommentCommand = Command.make(
95
+ "comment",
96
+ {
97
+ body: Flag.string("body").pipe(Flag.withDescription("Comment body text")),
98
+ format: formatOption,
99
+ issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number to comment on")),
100
+ },
101
+ ({ body, format, issue }) =>
102
+ Effect.gen(function* () {
103
+ const result = yield* commentOnIssue({ body, issue });
104
+ yield* logFormatted(result, format);
105
+ }),
106
+ ).pipe(Command.withDescription("Post a comment on an issue"));
107
+
108
+ export const issueEditCommand = Command.make(
109
+ "edit",
110
+ {
111
+ addAssignee: Flag.string("add-assignee").pipe(
112
+ Flag.withDescription("Add assignee login (comma-separated for multiple)"),
113
+ Flag.optional,
114
+ ),
115
+ addLabels: Flag.string("add-labels").pipe(
116
+ Flag.withDescription("Add labels (comma-separated)"),
117
+ Flag.optional,
118
+ ),
119
+ body: Flag.string("body").pipe(Flag.withDescription("New issue body"), Flag.optional),
120
+ format: formatOption,
121
+ issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number to edit")),
122
+ removeAssignee: Flag.string("remove-assignee").pipe(
123
+ Flag.withDescription("Remove assignee login (comma-separated for multiple)"),
124
+ Flag.optional,
125
+ ),
126
+ removeLabels: Flag.string("remove-labels").pipe(
127
+ Flag.withDescription("Remove labels (comma-separated)"),
128
+ Flag.optional,
129
+ ),
130
+ title: Flag.string("title").pipe(Flag.withDescription("New issue title"), Flag.optional),
131
+ },
132
+ ({ addAssignee, addLabels, body, format, issue, removeAssignee, removeLabels, title }) =>
133
+ Effect.gen(function* () {
134
+ const result = yield* editIssue({
135
+ addAssignee: Option.getOrNull(addAssignee),
136
+ addLabels: Option.getOrNull(addLabels),
137
+ body: Option.getOrNull(body),
138
+ issue,
139
+ removeAssignee: Option.getOrNull(removeAssignee),
140
+ removeLabels: Option.getOrNull(removeLabels),
141
+ title: Option.getOrNull(title),
142
+ });
143
+ yield* logFormatted(result, format);
144
+ }),
145
+ ).pipe(Command.withDescription("Edit issue title, body, labels, or assignees"));
@@ -0,0 +1,208 @@
1
+ import { Effect } from "effect";
2
+
3
+ import { GitHubCommandError } from "#gh/errors";
4
+ import { GitHubService } from "#gh/service";
5
+
6
+ export type IssueInfo = {
7
+ number: number;
8
+ title: string;
9
+ state: string;
10
+ url: string;
11
+ labels: Array<{ name: string }>;
12
+ assignees: Array<{ login: string }>;
13
+ author: { login: string };
14
+ createdAt: string;
15
+ closedAt: string | null;
16
+ };
17
+
18
+ export type IssueListItem = {
19
+ number: number;
20
+ title: string;
21
+ state: string;
22
+ url: string;
23
+ labels: Array<{ name: string }>;
24
+ createdAt: string;
25
+ };
26
+
27
+ export type RawIssueComment = {
28
+ id: number;
29
+ user: { login: string };
30
+ body: string;
31
+ created_at: string;
32
+ html_url: string;
33
+ };
34
+
35
+ export const listIssues = Effect.fn("issue.listIssues")(function* (opts: {
36
+ state: string;
37
+ labels: string | null;
38
+ limit: number;
39
+ }) {
40
+ const gh = yield* GitHubService;
41
+
42
+ const args = [
43
+ "issue",
44
+ "list",
45
+ "--state",
46
+ opts.state,
47
+ "--limit",
48
+ String(opts.limit),
49
+ "--json",
50
+ "number,title,state,url,labels,createdAt",
51
+ ];
52
+
53
+ if (opts.labels !== null) {
54
+ args.push("--label", opts.labels);
55
+ }
56
+
57
+ return yield* gh.runGhJson<IssueListItem[]>(args);
58
+ });
59
+
60
+ export const viewIssue = Effect.fn("issue.viewIssue")(function* (issueNumber: number) {
61
+ const gh = yield* GitHubService;
62
+
63
+ return yield* gh.runGhJson<IssueInfo>([
64
+ "issue",
65
+ "view",
66
+ String(issueNumber),
67
+ "--json",
68
+ "number,title,state,url,labels,assignees,author,createdAt,closedAt",
69
+ ]);
70
+ });
71
+
72
+ export const closeIssue = Effect.fn("issue.closeIssue")(function* (opts: {
73
+ issue: number;
74
+ comment: string | null;
75
+ reason: string;
76
+ }) {
77
+ const gh = yield* GitHubService;
78
+
79
+ const args = ["issue", "close", String(opts.issue), "--reason", opts.reason];
80
+
81
+ if (opts.comment !== null) {
82
+ args.push("--comment", opts.comment);
83
+ }
84
+
85
+ yield* gh.runGh(args);
86
+
87
+ return yield* gh.runGhJson<IssueInfo>([
88
+ "issue",
89
+ "view",
90
+ String(opts.issue),
91
+ "--json",
92
+ "number,title,state,url,labels,assignees,author,createdAt,closedAt",
93
+ ]);
94
+ });
95
+
96
+ export const reopenIssue = Effect.fn("issue.reopenIssue")(function* (opts: {
97
+ issue: number;
98
+ comment: string | null;
99
+ }) {
100
+ const gh = yield* GitHubService;
101
+
102
+ const args = ["issue", "reopen", String(opts.issue)];
103
+
104
+ if (opts.comment !== null) {
105
+ args.push("--comment", opts.comment);
106
+ }
107
+
108
+ yield* gh.runGh(args);
109
+
110
+ return yield* gh.runGhJson<IssueInfo>([
111
+ "issue",
112
+ "view",
113
+ String(opts.issue),
114
+ "--json",
115
+ "number,title,state,url,labels,assignees,author,createdAt,closedAt",
116
+ ]);
117
+ });
118
+
119
+ export const commentOnIssue = Effect.fn("issue.commentOnIssue")(function* (opts: {
120
+ issue: number;
121
+ body: string;
122
+ }) {
123
+ const gh = yield* GitHubService;
124
+ const repoInfo = yield* gh.getRepoInfo();
125
+
126
+ const trimmedBody = opts.body.trim();
127
+ if (trimmedBody.length === 0) {
128
+ return yield* Effect.fail(
129
+ new GitHubCommandError({
130
+ command: "gh-tool issue comment",
131
+ exitCode: 0,
132
+ stderr: "Comment body cannot be empty",
133
+ message: "Comment body cannot be empty",
134
+ }),
135
+ );
136
+ }
137
+
138
+ const result = yield* gh.runGh([
139
+ "api",
140
+ "-X",
141
+ "POST",
142
+ `repos/${repoInfo.owner}/${repoInfo.name}/issues/${opts.issue}/comments`,
143
+ "-f",
144
+ `body=${trimmedBody}`,
145
+ ]);
146
+
147
+ const rawComment = yield* Effect.try({
148
+ try: () => JSON.parse(result.stdout) as RawIssueComment,
149
+ catch: (error) =>
150
+ new GitHubCommandError({
151
+ command: "gh-tool issue comment",
152
+ exitCode: 0,
153
+ stderr: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
154
+ message: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
155
+ }),
156
+ }).pipe(Effect.mapError((error) => error as GitHubCommandError));
157
+
158
+ return {
159
+ id: rawComment.id,
160
+ author: rawComment.user.login,
161
+ body: rawComment.body,
162
+ createdAt: rawComment.created_at,
163
+ url: rawComment.html_url,
164
+ };
165
+ });
166
+
167
+ export const editIssue = Effect.fn("issue.editIssue")(function* (opts: {
168
+ issue: number;
169
+ title: string | null;
170
+ body: string | null;
171
+ addLabels: string | null;
172
+ removeLabels: string | null;
173
+ addAssignee: string | null;
174
+ removeAssignee: string | null;
175
+ }) {
176
+ const gh = yield* GitHubService;
177
+
178
+ const args = ["issue", "edit", String(opts.issue)];
179
+
180
+ if (opts.title !== null) {
181
+ args.push("--title", opts.title);
182
+ }
183
+ if (opts.body !== null) {
184
+ args.push("--body", opts.body);
185
+ }
186
+ if (opts.addLabels !== null) {
187
+ args.push("--add-label", opts.addLabels);
188
+ }
189
+ if (opts.removeLabels !== null) {
190
+ args.push("--remove-label", opts.removeLabels);
191
+ }
192
+ if (opts.addAssignee !== null) {
193
+ args.push("--add-assignee", opts.addAssignee);
194
+ }
195
+ if (opts.removeAssignee !== null) {
196
+ args.push("--remove-assignee", opts.removeAssignee);
197
+ }
198
+
199
+ yield* gh.runGh(args);
200
+
201
+ return yield* gh.runGhJson<IssueInfo>([
202
+ "issue",
203
+ "view",
204
+ String(opts.issue),
205
+ "--json",
206
+ "number,title,state,url,labels,assignees,author,createdAt,closedAt",
207
+ ]);
208
+ });
@@ -0,0 +1,9 @@
1
+ export {
2
+ issueCloseCommand,
3
+ issueCommentCommand,
4
+ issueEditCommand,
5
+ issueListCommand,
6
+ issueReopenCommand,
7
+ issueViewCommand,
8
+ } from "./commands";
9
+ export { issueTriageSummaryCommand } from "./triage";
@@ -0,0 +1,320 @@
1
+ import { Command, Flag } from "effect/unstable/cli";
2
+ import { Effect } from "effect";
3
+
4
+ import { formatOption, logFormatted } from "#shared";
5
+ import { GitHubService } from "#gh/service";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Raw types (gh CLI JSON output)
9
+ // ---------------------------------------------------------------------------
10
+
11
+ type RawTriageIssue = {
12
+ number: number;
13
+ title: string;
14
+ state: string;
15
+ url: string;
16
+ labels: Array<{ name: string }>;
17
+ assignees: Array<{ login: string }>;
18
+ author: { login: string };
19
+ body: string;
20
+ comments: Array<unknown>;
21
+ createdAt: string;
22
+ };
23
+
24
+ type RawTriagePR = {
25
+ number: number;
26
+ title: string;
27
+ state: string;
28
+ url: string;
29
+ labels: Array<{ name: string }>;
30
+ author: { login: string };
31
+ body: string;
32
+ headRefName: string;
33
+ baseRefName: string;
34
+ isDraft: boolean;
35
+ mergeable: string;
36
+ reviewDecision: string;
37
+ statusCheckRollup: Array<{
38
+ name: string;
39
+ status: string;
40
+ conclusion: string | null;
41
+ context: string;
42
+ }>;
43
+ };
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Classification types
47
+ // ---------------------------------------------------------------------------
48
+
49
+ type IssueClassification = "QUESTION" | "BUG" | "FEATURE" | "OTHER";
50
+ type PRClassification = "BUGFIX" | "OTHER";
51
+ type Confidence = "HIGH" | "MEDIUM" | "LOW";
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Output types
55
+ // ---------------------------------------------------------------------------
56
+
57
+ type TriageIssue = {
58
+ number: number;
59
+ title: string;
60
+ author: string;
61
+ labels: string[];
62
+ classification: IssueClassification;
63
+ confidence: Confidence;
64
+ body: string;
65
+ commentsCount: number;
66
+ createdAt: string;
67
+ url: string;
68
+ };
69
+
70
+ type TriagePR = {
71
+ number: number;
72
+ title: string;
73
+ author: string;
74
+ labels: string[];
75
+ classification: PRClassification;
76
+ confidence: Confidence;
77
+ headRefName: string;
78
+ baseRefName: string;
79
+ isDraft: boolean;
80
+ mergeable: string;
81
+ reviewDecision: string;
82
+ ciStatus: string;
83
+ body: string;
84
+ url: string;
85
+ };
86
+
87
+ type TriageSummary = {
88
+ repo: string;
89
+ fetchedAt: string;
90
+ issues: TriageIssue[];
91
+ prs: TriagePR[];
92
+ summary: {
93
+ totalIssues: number;
94
+ totalPRs: number;
95
+ issuesByType: Record<string, number>;
96
+ prsByType: Record<string, number>;
97
+ };
98
+ };
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Classification logic (pure functions)
102
+ // ---------------------------------------------------------------------------
103
+
104
+ function classifyIssue(
105
+ labels: string[],
106
+ title: string,
107
+ ): { classification: IssueClassification; confidence: Confidence } {
108
+ const lowerLabels = new Set(labels.map((l) => l.toLowerCase()));
109
+ const lowerTitle = title.toLowerCase();
110
+
111
+ // Labels first — HIGH confidence
112
+ if (lowerLabels.has("bug")) {
113
+ return { classification: "BUG", confidence: "HIGH" };
114
+ }
115
+ if (lowerLabels.has("question") || lowerLabels.has("help wanted")) {
116
+ return { classification: "QUESTION", confidence: "HIGH" };
117
+ }
118
+ if (
119
+ lowerLabels.has("enhancement") ||
120
+ lowerLabels.has("feature") ||
121
+ lowerLabels.has("feature request")
122
+ ) {
123
+ return { classification: "FEATURE", confidence: "HIGH" };
124
+ }
125
+
126
+ // Title patterns — MEDIUM confidence
127
+ if (/\[bug\]/i.test(title) || /^bug:/i.test(title) || /^fix:/i.test(title)) {
128
+ return { classification: "BUG", confidence: "MEDIUM" };
129
+ }
130
+ if (
131
+ lowerTitle.includes("?") ||
132
+ /\[question\]/i.test(title) ||
133
+ /how to/i.test(title) ||
134
+ /is it possible/i.test(title)
135
+ ) {
136
+ return { classification: "QUESTION", confidence: "MEDIUM" };
137
+ }
138
+ if (
139
+ /\[feature\]/i.test(title) ||
140
+ /\[enhancement\]/i.test(title) ||
141
+ /\[rfe\]/i.test(title) ||
142
+ /^feat:/i.test(title)
143
+ ) {
144
+ return { classification: "FEATURE", confidence: "MEDIUM" };
145
+ }
146
+
147
+ // Default — LOW confidence
148
+ return { classification: "OTHER", confidence: "LOW" };
149
+ }
150
+
151
+ function classifyPR(
152
+ labels: string[],
153
+ title: string,
154
+ branch: string,
155
+ ): { classification: PRClassification; confidence: Confidence } {
156
+ const lowerLabels = new Set(labels.map((l) => l.toLowerCase()));
157
+
158
+ // Labels first — HIGH confidence
159
+ if (lowerLabels.has("bug")) {
160
+ return { classification: "BUGFIX", confidence: "HIGH" };
161
+ }
162
+
163
+ // Title/branch patterns — MEDIUM confidence
164
+ if (/^fix/i.test(title)) {
165
+ return { classification: "BUGFIX", confidence: "MEDIUM" };
166
+ }
167
+ if (branch.startsWith("fix/") || branch.startsWith("bugfix/")) {
168
+ return { classification: "BUGFIX", confidence: "MEDIUM" };
169
+ }
170
+
171
+ // Default — LOW confidence
172
+ return { classification: "OTHER", confidence: "LOW" };
173
+ }
174
+
175
+ function aggregateCIStatus(checks: RawTriagePR["statusCheckRollup"]): string {
176
+ if (checks.length === 0) return "UNKNOWN";
177
+ if (checks.some((c) => c.conclusion === "failure")) return "FAIL";
178
+ if (checks.some((c) => c.status !== "COMPLETED")) return "PENDING";
179
+ return "PASS";
180
+ }
181
+
182
+ function truncateBody(body: string, maxLength = 500): string {
183
+ if (body.length <= maxLength) return body;
184
+ return body.slice(0, maxLength) + "…";
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Handler
189
+ // ---------------------------------------------------------------------------
190
+
191
+ const fetchTriageSummary = Effect.fn("issue.fetchTriageSummary")(function* (opts: {
192
+ state: string;
193
+ limit: number;
194
+ }) {
195
+ const gh = yield* GitHubService;
196
+ const repoInfo = yield* gh.getRepoInfo();
197
+
198
+ // Parallel fetch: issues + PRs
199
+ const [rawIssues, rawPRs] = yield* Effect.all(
200
+ [
201
+ gh.runGhJson<RawTriageIssue[]>([
202
+ "issue",
203
+ "list",
204
+ "--state",
205
+ opts.state,
206
+ "--limit",
207
+ String(opts.limit),
208
+ "--json",
209
+ "number,title,state,url,labels,assignees,author,body,comments,createdAt",
210
+ ]),
211
+ gh.runGhJson<RawTriagePR[]>([
212
+ "pr",
213
+ "list",
214
+ "--state",
215
+ opts.state,
216
+ "--limit",
217
+ String(opts.limit),
218
+ "--json",
219
+ "number,title,state,url,labels,author,body,headRefName,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup",
220
+ ]),
221
+ ],
222
+ { concurrency: "unbounded" },
223
+ );
224
+
225
+ // Classify + transform issues
226
+ const issues: TriageIssue[] = rawIssues.map((issue) => {
227
+ const labelNames = issue.labels.map((l) => l.name);
228
+ const { classification, confidence } = classifyIssue(labelNames, issue.title);
229
+
230
+ return {
231
+ number: issue.number,
232
+ title: issue.title,
233
+ author: issue.author.login,
234
+ labels: labelNames,
235
+ classification,
236
+ confidence,
237
+ body: truncateBody(issue.body),
238
+ commentsCount: issue.comments.length,
239
+ createdAt: issue.createdAt,
240
+ url: issue.url,
241
+ };
242
+ });
243
+
244
+ // Classify + transform PRs
245
+ const prs: TriagePR[] = rawPRs.map((pr) => {
246
+ const labelNames = pr.labels.map((l) => l.name);
247
+ const { classification, confidence } = classifyPR(labelNames, pr.title, pr.headRefName);
248
+
249
+ return {
250
+ number: pr.number,
251
+ title: pr.title,
252
+ author: pr.author.login,
253
+ labels: labelNames,
254
+ classification,
255
+ confidence,
256
+ headRefName: pr.headRefName,
257
+ baseRefName: pr.baseRefName,
258
+ isDraft: pr.isDraft,
259
+ mergeable: pr.mergeable,
260
+ reviewDecision: pr.reviewDecision,
261
+ ciStatus: aggregateCIStatus(pr.statusCheckRollup ?? []),
262
+ body: truncateBody(pr.body),
263
+ url: pr.url,
264
+ };
265
+ });
266
+
267
+ // Build summary counters
268
+ const issuesByType: Record<string, number> = {};
269
+ for (const issue of issues) {
270
+ issuesByType[issue.classification] = (issuesByType[issue.classification] ?? 0) + 1;
271
+ }
272
+
273
+ const prsByType: Record<string, number> = {};
274
+ for (const pr of prs) {
275
+ prsByType[pr.classification] = (prsByType[pr.classification] ?? 0) + 1;
276
+ }
277
+
278
+ const result: TriageSummary = {
279
+ repo: `${repoInfo.owner}/${repoInfo.name}`,
280
+ fetchedAt: new Date().toISOString(),
281
+ issues,
282
+ prs,
283
+ summary: {
284
+ totalIssues: issues.length,
285
+ totalPRs: prs.length,
286
+ issuesByType,
287
+ prsByType,
288
+ },
289
+ };
290
+
291
+ return result;
292
+ });
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // CLI Command
296
+ // ---------------------------------------------------------------------------
297
+
298
+ export const issueTriageSummaryCommand = Command.make(
299
+ "triage-summary",
300
+ {
301
+ format: formatOption,
302
+ limit: Flag.integer("limit").pipe(
303
+ Flag.withDescription("Maximum number of issues and PRs to fetch"),
304
+ Flag.withDefault(100),
305
+ ),
306
+ state: Flag.choice("state", ["open", "closed", "all"]).pipe(
307
+ Flag.withDescription("Filter by state: open, closed, all"),
308
+ Flag.withDefault("open"),
309
+ ),
310
+ },
311
+ ({ format, limit, state }) =>
312
+ Effect.gen(function* () {
313
+ const summary = yield* fetchTriageSummary({ limit, state });
314
+ yield* logFormatted(summary, format);
315
+ }),
316
+ ).pipe(
317
+ Command.withDescription(
318
+ "Composite: fetch all issues + PRs, classify each, return structured triage summary",
319
+ ),
320
+ );
@@ -449,7 +449,14 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
449
449
  return yield* gh.runGhJson<CheckResult[]>([...args, "--json", "name,state,bucket,link"]);
450
450
  }
451
451
 
452
- return yield* gh.runGhJson<CheckResult[]>([...args, "--json", "name,state,bucket,link"]);
452
+ const results = yield* gh.runGhJson<CheckResult[]>([...args, "--json", "name,state,bucket,link"]);
453
+ if (results.some((c) => c.bucket === "pending")) {
454
+ yield* Console.warn(
455
+ `ℹ️ 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`,
457
+ );
458
+ }
459
+ return results;
453
460
  });
454
461
 
455
462
  export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (pr: number | null) {
@@ -68,8 +68,10 @@ export function isKubectlCommandAllowed(cmd: string): K8sSecurityCheckResult {
68
68
  const kubectlPart = trimmed.split("|")[0].trim();
69
69
 
70
70
  // Extract verb: first non-flag word
71
- const words = kubectlPart.split(/\s+/).filter((w) => !w.startsWith("-"));
72
- const verb = words[0]?.toLowerCase();
71
+ const verb = kubectlPart
72
+ .split(/\s+/)
73
+ .find((word) => !word.startsWith("-"))
74
+ ?.toLowerCase();
73
75
 
74
76
  if (!verb) {
75
77
  return { allowed: false, command: cmd, reason: "Empty kubectl command." };
@@ -32,12 +32,10 @@ const formatError = (error: unknown): string => {
32
32
  };
33
33
 
34
34
  const formatCause = (cause: Cause.Cause<unknown>): string => {
35
- const failures = cause.reasons.filter(Cause.isFailReason);
36
- const firstFailure = failures[0];
35
+ const firstFailure = cause.reasons.find(Cause.isFailReason);
37
36
  if (firstFailure !== undefined) return formatError(firstFailure.error);
38
37
 
39
- const defects = cause.reasons.filter(Cause.isDieReason);
40
- const firstDefect = defects[0];
38
+ const firstDefect = cause.reasons.find(Cause.isDieReason);
41
39
  if (firstDefect !== undefined) {
42
40
  if (firstDefect.defect instanceof Error)
43
41
  return `Unexpected error: ${firstDefect.defect.message}`;
@@ -1,361 +0,0 @@
1
- import { Command, Flag } from "effect/unstable/cli";
2
- import { Effect, Option } from "effect";
3
-
4
- import { formatOption, logFormatted } from "#shared";
5
- import { GitHubCommandError } from "./errors";
6
- import { GitHubService } from "./service";
7
-
8
- // ---------------------------------------------------------------------------
9
- // Types
10
- // ---------------------------------------------------------------------------
11
-
12
- type IssueInfo = {
13
- number: number;
14
- title: string;
15
- state: string;
16
- url: string;
17
- labels: Array<{ name: string }>;
18
- assignees: Array<{ login: string }>;
19
- author: { login: string };
20
- createdAt: string;
21
- closedAt: string | null;
22
- };
23
-
24
- type IssueListItem = {
25
- number: number;
26
- title: string;
27
- state: string;
28
- url: string;
29
- labels: Array<{ name: string }>;
30
- createdAt: string;
31
- };
32
-
33
- type RawIssueComment = {
34
- id: number;
35
- user: { login: string };
36
- body: string;
37
- created_at: string;
38
- html_url: string;
39
- };
40
-
41
- // ---------------------------------------------------------------------------
42
- // Handlers
43
- // ---------------------------------------------------------------------------
44
-
45
- const listIssues = Effect.fn("issue.listIssues")(function* (opts: {
46
- state: string;
47
- labels: string | null;
48
- limit: number;
49
- }) {
50
- const gh = yield* GitHubService;
51
-
52
- const args = [
53
- "issue",
54
- "list",
55
- "--state",
56
- opts.state,
57
- "--limit",
58
- String(opts.limit),
59
- "--json",
60
- "number,title,state,url,labels,createdAt",
61
- ];
62
-
63
- if (opts.labels !== null) {
64
- args.push("--label", opts.labels);
65
- }
66
-
67
- return yield* gh.runGhJson<IssueListItem[]>(args);
68
- });
69
-
70
- const viewIssue = Effect.fn("issue.viewIssue")(function* (issueNumber: number) {
71
- const gh = yield* GitHubService;
72
-
73
- return yield* gh.runGhJson<IssueInfo>([
74
- "issue",
75
- "view",
76
- String(issueNumber),
77
- "--json",
78
- "number,title,state,url,labels,assignees,author,createdAt,closedAt",
79
- ]);
80
- });
81
-
82
- const closeIssue = Effect.fn("issue.closeIssue")(function* (opts: {
83
- issue: number;
84
- comment: string | null;
85
- reason: string;
86
- }) {
87
- const gh = yield* GitHubService;
88
-
89
- const args = ["issue", "close", String(opts.issue), "--reason", opts.reason];
90
-
91
- if (opts.comment !== null) {
92
- args.push("--comment", opts.comment);
93
- }
94
-
95
- yield* gh.runGh(args);
96
-
97
- return yield* gh.runGhJson<IssueInfo>([
98
- "issue",
99
- "view",
100
- String(opts.issue),
101
- "--json",
102
- "number,title,state,url,labels,assignees,author,createdAt,closedAt",
103
- ]);
104
- });
105
-
106
- const reopenIssue = Effect.fn("issue.reopenIssue")(function* (opts: {
107
- issue: number;
108
- comment: string | null;
109
- }) {
110
- const gh = yield* GitHubService;
111
-
112
- const args = ["issue", "reopen", String(opts.issue)];
113
-
114
- if (opts.comment !== null) {
115
- args.push("--comment", opts.comment);
116
- }
117
-
118
- yield* gh.runGh(args);
119
-
120
- return yield* gh.runGhJson<IssueInfo>([
121
- "issue",
122
- "view",
123
- String(opts.issue),
124
- "--json",
125
- "number,title,state,url,labels,assignees,author,createdAt,closedAt",
126
- ]);
127
- });
128
-
129
- const commentOnIssue = Effect.fn("issue.commentOnIssue")(function* (opts: {
130
- issue: number;
131
- body: string;
132
- }) {
133
- const gh = yield* GitHubService;
134
- const repoInfo = yield* gh.getRepoInfo();
135
-
136
- const trimmedBody = opts.body.trim();
137
- if (trimmedBody.length === 0) {
138
- return yield* Effect.fail(
139
- new GitHubCommandError({
140
- command: "gh-tool issue comment",
141
- exitCode: 0,
142
- stderr: "Comment body cannot be empty",
143
- message: "Comment body cannot be empty",
144
- }),
145
- );
146
- }
147
-
148
- const result = yield* gh.runGh([
149
- "api",
150
- "-X",
151
- "POST",
152
- `repos/${repoInfo.owner}/${repoInfo.name}/issues/${opts.issue}/comments`,
153
- "-f",
154
- `body=${trimmedBody}`,
155
- ]);
156
-
157
- const rawComment = yield* Effect.try({
158
- try: () => JSON.parse(result.stdout) as RawIssueComment,
159
- catch: (error) =>
160
- new GitHubCommandError({
161
- command: "gh-tool issue comment",
162
- exitCode: 0,
163
- stderr: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
164
- message: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
165
- }),
166
- }).pipe(Effect.mapError((error) => error as GitHubCommandError));
167
-
168
- return {
169
- id: rawComment.id,
170
- author: rawComment.user.login,
171
- body: rawComment.body,
172
- createdAt: rawComment.created_at,
173
- url: rawComment.html_url,
174
- };
175
- });
176
-
177
- const editIssue = Effect.fn("issue.editIssue")(function* (opts: {
178
- issue: number;
179
- title: string | null;
180
- body: string | null;
181
- addLabels: string | null;
182
- removeLabels: string | null;
183
- addAssignee: string | null;
184
- removeAssignee: string | null;
185
- }) {
186
- const gh = yield* GitHubService;
187
-
188
- const args = ["issue", "edit", String(opts.issue)];
189
-
190
- if (opts.title !== null) {
191
- args.push("--title", opts.title);
192
- }
193
- if (opts.body !== null) {
194
- args.push("--body", opts.body);
195
- }
196
- if (opts.addLabels !== null) {
197
- args.push("--add-label", opts.addLabels);
198
- }
199
- if (opts.removeLabels !== null) {
200
- args.push("--remove-label", opts.removeLabels);
201
- }
202
- if (opts.addAssignee !== null) {
203
- args.push("--add-assignee", opts.addAssignee);
204
- }
205
- if (opts.removeAssignee !== null) {
206
- args.push("--remove-assignee", opts.removeAssignee);
207
- }
208
-
209
- yield* gh.runGh(args);
210
-
211
- return yield* gh.runGhJson<IssueInfo>([
212
- "issue",
213
- "view",
214
- String(opts.issue),
215
- "--json",
216
- "number,title,state,url,labels,assignees,author,createdAt,closedAt",
217
- ]);
218
- });
219
-
220
- // ---------------------------------------------------------------------------
221
- // CLI Commands
222
- // ---------------------------------------------------------------------------
223
-
224
- export const issueListCommand = Command.make(
225
- "list",
226
- {
227
- format: formatOption,
228
- labels: Flag.string("labels").pipe(
229
- Flag.withDescription("Filter by label (comma-separated)"),
230
- Flag.optional,
231
- ),
232
- limit: Flag.integer("limit").pipe(
233
- Flag.withDescription("Maximum number of issues to return"),
234
- Flag.withDefault(30),
235
- ),
236
- state: Flag.choice("state", ["open", "closed", "all"]).pipe(
237
- Flag.withDescription("Filter by state: open, closed, all"),
238
- Flag.withDefault("open"),
239
- ),
240
- },
241
- ({ format, labels, limit, state }) =>
242
- Effect.gen(function* () {
243
- const issues = yield* listIssues({
244
- labels: Option.getOrNull(labels),
245
- limit,
246
- state,
247
- });
248
- yield* logFormatted(issues, format);
249
- }),
250
- ).pipe(Command.withDescription("List issues (default: open, use --state to filter)"));
251
-
252
- export const issueViewCommand = Command.make(
253
- "view",
254
- {
255
- format: formatOption,
256
- issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number")),
257
- },
258
- ({ format, issue }) =>
259
- Effect.gen(function* () {
260
- const info = yield* viewIssue(issue);
261
- yield* logFormatted(info, format);
262
- }),
263
- ).pipe(Command.withDescription("View issue details"));
264
-
265
- export const issueCloseCommand = Command.make(
266
- "close",
267
- {
268
- comment: Flag.string("comment").pipe(
269
- Flag.withDescription("Comment to add when closing"),
270
- Flag.optional,
271
- ),
272
- format: formatOption,
273
- issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number to close")),
274
- reason: Flag.choice("reason", ["completed", "not planned"]).pipe(
275
- Flag.withDescription("Close reason: completed, not planned"),
276
- Flag.withDefault("completed"),
277
- ),
278
- },
279
- ({ comment, format, issue, reason }) =>
280
- Effect.gen(function* () {
281
- const result = yield* closeIssue({
282
- comment: Option.getOrNull(comment),
283
- issue,
284
- reason,
285
- });
286
- yield* logFormatted(result, format);
287
- }),
288
- ).pipe(Command.withDescription("Close an issue with optional comment and reason"));
289
-
290
- export const issueReopenCommand = Command.make(
291
- "reopen",
292
- {
293
- comment: Flag.string("comment").pipe(
294
- Flag.withDescription("Comment to add when reopening"),
295
- Flag.optional,
296
- ),
297
- format: formatOption,
298
- issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number to reopen")),
299
- },
300
- ({ comment, format, issue }) =>
301
- Effect.gen(function* () {
302
- const result = yield* reopenIssue({
303
- comment: Option.getOrNull(comment),
304
- issue,
305
- });
306
- yield* logFormatted(result, format);
307
- }),
308
- ).pipe(Command.withDescription("Reopen a closed issue"));
309
-
310
- export const issueCommentCommand = Command.make(
311
- "comment",
312
- {
313
- body: Flag.string("body").pipe(Flag.withDescription("Comment body text")),
314
- format: formatOption,
315
- issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number to comment on")),
316
- },
317
- ({ body, format, issue }) =>
318
- Effect.gen(function* () {
319
- const result = yield* commentOnIssue({ body, issue });
320
- yield* logFormatted(result, format);
321
- }),
322
- ).pipe(Command.withDescription("Post a comment on an issue"));
323
-
324
- export const issueEditCommand = Command.make(
325
- "edit",
326
- {
327
- addAssignee: Flag.string("add-assignee").pipe(
328
- Flag.withDescription("Add assignee login (comma-separated for multiple)"),
329
- Flag.optional,
330
- ),
331
- addLabels: Flag.string("add-labels").pipe(
332
- Flag.withDescription("Add labels (comma-separated)"),
333
- Flag.optional,
334
- ),
335
- body: Flag.string("body").pipe(Flag.withDescription("New issue body"), Flag.optional),
336
- format: formatOption,
337
- issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number to edit")),
338
- removeAssignee: Flag.string("remove-assignee").pipe(
339
- Flag.withDescription("Remove assignee login (comma-separated for multiple)"),
340
- Flag.optional,
341
- ),
342
- removeLabels: Flag.string("remove-labels").pipe(
343
- Flag.withDescription("Remove labels (comma-separated)"),
344
- Flag.optional,
345
- ),
346
- title: Flag.string("title").pipe(Flag.withDescription("New issue title"), Flag.optional),
347
- },
348
- ({ addAssignee, addLabels, body, format, issue, removeAssignee, removeLabels, title }) =>
349
- Effect.gen(function* () {
350
- const result = yield* editIssue({
351
- addAssignee: Option.getOrNull(addAssignee),
352
- addLabels: Option.getOrNull(addLabels),
353
- body: Option.getOrNull(body),
354
- issue,
355
- removeAssignee: Option.getOrNull(removeAssignee),
356
- removeLabels: Option.getOrNull(removeLabels),
357
- title: Option.getOrNull(title),
358
- });
359
- yield* logFormatted(result, format);
360
- }),
361
- ).pipe(Command.withDescription("Edit issue title, body, labels, or assignees"));