@blogic-cz/agent-tools 0.8.11 → 0.8.13
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 +1 -1
- package/src/gh-tool/index.ts +19 -15
- package/src/gh-tool/issue/commands.ts +39 -1
- package/src/gh-tool/issue/core.ts +91 -0
- package/src/gh-tool/issue/index.ts +2 -1
- package/src/gh-tool/issue/triage.ts +72 -254
- package/src/k8s-tool/transformers.ts +96 -14
package/package.json
CHANGED
package/src/gh-tool/index.ts
CHANGED
|
@@ -9,10 +9,11 @@ import { ConfigServiceLayer } from "#config";
|
|
|
9
9
|
import {
|
|
10
10
|
issueCloseCommand,
|
|
11
11
|
issueCommentCommand,
|
|
12
|
+
issueCommentsCommand,
|
|
12
13
|
issueEditCommand,
|
|
13
14
|
issueListCommand,
|
|
14
15
|
issueReopenCommand,
|
|
15
|
-
|
|
16
|
+
issueTriageCommand,
|
|
16
17
|
issueViewCommand,
|
|
17
18
|
} from "./issue";
|
|
18
19
|
import {
|
|
@@ -85,16 +86,17 @@ const prCommand = Command.make("pr", {}).pipe(
|
|
|
85
86
|
|
|
86
87
|
const issueCommand = Command.make("issue", {}).pipe(
|
|
87
88
|
Command.withDescription(
|
|
88
|
-
"Issue operations (list, view, close, reopen, comment, edit
|
|
89
|
+
"Issue operations (list, view, comments, triage, close, reopen, comment, edit)",
|
|
89
90
|
),
|
|
90
91
|
Command.withSubcommands([
|
|
91
92
|
issueListCommand,
|
|
92
93
|
issueViewCommand,
|
|
94
|
+
issueCommentsCommand,
|
|
95
|
+
issueTriageCommand,
|
|
93
96
|
issueCloseCommand,
|
|
94
97
|
issueReopenCommand,
|
|
95
98
|
issueCommentCommand,
|
|
96
99
|
issueEditCommand,
|
|
97
|
-
issueTriageSummaryCommand,
|
|
98
100
|
]),
|
|
99
101
|
);
|
|
100
102
|
|
|
@@ -147,18 +149,20 @@ WORKFLOW FOR AI AGENTS:
|
|
|
147
149
|
5. Use 'pr checks' to monitor CI status
|
|
148
150
|
6. Use 'pr merge' to merge (dry-run by default)
|
|
149
151
|
7. Use 'issue list' to list open/closed issues
|
|
150
|
-
8. Use 'issue
|
|
151
|
-
|
|
152
|
-
10. Use '
|
|
153
|
-
11. Use '
|
|
154
|
-
12. Use '
|
|
155
|
-
13. Use 'workflow
|
|
156
|
-
14. Use 'workflow
|
|
157
|
-
15. Use 'workflow
|
|
158
|
-
16. Use 'workflow
|
|
159
|
-
17. Use '
|
|
160
|
-
18. Use '
|
|
161
|
-
19. Use 'release
|
|
152
|
+
8. Use 'issue triage --issue N --verbosity full' to inspect one issue in one call
|
|
153
|
+
9. Use 'issue comments --issue N' to read issue discussion comments separately
|
|
154
|
+
10. Use 'issue close --issue N --comment "reason"' to close issues
|
|
155
|
+
11. Use 'issue comment --issue N --body "text"' to comment on issues
|
|
156
|
+
12. Use 'repo info' to get repository metadata
|
|
157
|
+
13. Use 'workflow list' to list recent workflow runs
|
|
158
|
+
14. Use 'workflow view --run N' to inspect a specific run with jobs/steps
|
|
159
|
+
15. Use 'workflow logs --run N' to get logs (failed jobs by default)
|
|
160
|
+
16. Use 'workflow job-logs --run N --job "build-web-app"' to get clean parsed logs for a specific job
|
|
161
|
+
17. Use 'workflow annotations --run N' to list CI annotations (errors, warnings, notices)
|
|
162
|
+
18. Use 'workflow watch --run N' to watch until completion
|
|
163
|
+
19. Use 'release status' to inspect latest release + repository context
|
|
164
|
+
20. Use 'release create --tag vX.Y.Z --generate-notes' to publish a release
|
|
165
|
+
21. Use 'release edit/view/list/delete' to maintain existing releases`,
|
|
162
166
|
),
|
|
163
167
|
Command.withSubcommands([prCommand, issueCommand, repoCommand, workflowCommand, releaseCommand]),
|
|
164
168
|
);
|
|
@@ -3,7 +3,15 @@ import { Effect, Option } from "effect";
|
|
|
3
3
|
|
|
4
4
|
import { formatOption, logFormatted } from "#shared";
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
closeIssue,
|
|
8
|
+
commentOnIssue,
|
|
9
|
+
editIssue,
|
|
10
|
+
fetchIssueComments,
|
|
11
|
+
listIssues,
|
|
12
|
+
reopenIssue,
|
|
13
|
+
viewIssue,
|
|
14
|
+
} from "./core";
|
|
7
15
|
|
|
8
16
|
export const issueListCommand = Command.make(
|
|
9
17
|
"list",
|
|
@@ -46,6 +54,36 @@ export const issueViewCommand = Command.make(
|
|
|
46
54
|
}),
|
|
47
55
|
).pipe(Command.withDescription("View issue details"));
|
|
48
56
|
|
|
57
|
+
export const issueCommentsCommand = Command.make(
|
|
58
|
+
"comments",
|
|
59
|
+
{
|
|
60
|
+
author: Flag.string("author").pipe(
|
|
61
|
+
Flag.withDescription("Filter by author login substring"),
|
|
62
|
+
Flag.optional,
|
|
63
|
+
),
|
|
64
|
+
bodyContains: Flag.string("body-contains").pipe(
|
|
65
|
+
Flag.withDescription("Filter comments by body substring"),
|
|
66
|
+
Flag.optional,
|
|
67
|
+
),
|
|
68
|
+
format: formatOption,
|
|
69
|
+
issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number")),
|
|
70
|
+
since: Flag.string("since").pipe(
|
|
71
|
+
Flag.withDescription("ISO timestamp to filter comments created after"),
|
|
72
|
+
Flag.optional,
|
|
73
|
+
),
|
|
74
|
+
},
|
|
75
|
+
({ author, bodyContains, format, issue, since }) =>
|
|
76
|
+
Effect.gen(function* () {
|
|
77
|
+
const comments = yield* fetchIssueComments(
|
|
78
|
+
issue,
|
|
79
|
+
Option.getOrNull(since),
|
|
80
|
+
Option.getOrNull(author),
|
|
81
|
+
Option.getOrNull(bodyContains),
|
|
82
|
+
);
|
|
83
|
+
yield* logFormatted(comments, format);
|
|
84
|
+
}),
|
|
85
|
+
).pipe(Command.withDescription("Fetch issue discussion comments"));
|
|
86
|
+
|
|
49
87
|
export const issueCloseCommand = Command.make(
|
|
50
88
|
"close",
|
|
51
89
|
{
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
|
|
3
|
+
import type { GitHubIssueCommentUrl, IssueComment, IssueCommentId, IsoTimestamp } from "#gh/types";
|
|
4
|
+
|
|
3
5
|
import { GitHubCommandError } from "#gh/errors";
|
|
4
6
|
import { GitHubService } from "#gh/service";
|
|
5
7
|
|
|
@@ -32,6 +34,60 @@ export type RawIssueComment = {
|
|
|
32
34
|
html_url: string;
|
|
33
35
|
};
|
|
34
36
|
|
|
37
|
+
const REST_PAGE_SIZE = 100;
|
|
38
|
+
|
|
39
|
+
const parseJson = <T>(
|
|
40
|
+
stdout: string,
|
|
41
|
+
command: string,
|
|
42
|
+
parseFailurePrefix: string,
|
|
43
|
+
): Effect.Effect<T, GitHubCommandError> =>
|
|
44
|
+
Effect.try({
|
|
45
|
+
try: () => JSON.parse(stdout) as T,
|
|
46
|
+
catch: (error) =>
|
|
47
|
+
new GitHubCommandError({
|
|
48
|
+
command,
|
|
49
|
+
exitCode: 0,
|
|
50
|
+
stderr: `${parseFailurePrefix}: ${error instanceof Error ? error.message : String(error)}`,
|
|
51
|
+
message: `${parseFailurePrefix}: ${error instanceof Error ? error.message : String(error)}`,
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const fetchAllRestPages = Effect.fn("issue.fetchAllRestPages")(function* <T>(
|
|
56
|
+
endpoint: string,
|
|
57
|
+
command: string,
|
|
58
|
+
parseFailurePrefix: string,
|
|
59
|
+
) {
|
|
60
|
+
const service = yield* GitHubService;
|
|
61
|
+
|
|
62
|
+
const results: T[] = [];
|
|
63
|
+
let page = 1;
|
|
64
|
+
|
|
65
|
+
while (true) {
|
|
66
|
+
const separator = endpoint.includes("?") ? "&" : "?";
|
|
67
|
+
const result = yield* service.runGh([
|
|
68
|
+
"api",
|
|
69
|
+
`${endpoint}${separator}per_page=${REST_PAGE_SIZE}&page=${page}`,
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
const rawPage = yield* parseJson<T[]>(result.stdout, command, parseFailurePrefix);
|
|
73
|
+
results.push(...rawPage);
|
|
74
|
+
|
|
75
|
+
if (rawPage.length < REST_PAGE_SIZE) {
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
page += 1;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const mapRawIssueComment = (comment: RawIssueComment): IssueComment => ({
|
|
84
|
+
id: comment.id as IssueCommentId,
|
|
85
|
+
author: comment.user.login,
|
|
86
|
+
body: comment.body,
|
|
87
|
+
createdAt: comment.created_at as IsoTimestamp,
|
|
88
|
+
url: comment.html_url as GitHubIssueCommentUrl,
|
|
89
|
+
});
|
|
90
|
+
|
|
35
91
|
export const listIssues = Effect.fn("issue.listIssues")(function* (opts: {
|
|
36
92
|
state: string;
|
|
37
93
|
labels: string | null;
|
|
@@ -69,6 +125,41 @@ export const viewIssue = Effect.fn("issue.viewIssue")(function* (issueNumber: nu
|
|
|
69
125
|
]);
|
|
70
126
|
});
|
|
71
127
|
|
|
128
|
+
export const fetchIssueComments = Effect.fn("issue.fetchIssueComments")(function* (
|
|
129
|
+
issueNumber: number,
|
|
130
|
+
since: string | null,
|
|
131
|
+
author: string | null,
|
|
132
|
+
bodyContains: string | null,
|
|
133
|
+
) {
|
|
134
|
+
const gh = yield* GitHubService;
|
|
135
|
+
const repoInfo = yield* gh.getRepoInfo();
|
|
136
|
+
|
|
137
|
+
const raw = yield* fetchAllRestPages<RawIssueComment>(
|
|
138
|
+
`repos/${repoInfo.owner}/${repoInfo.name}/issues/${issueNumber}/comments`,
|
|
139
|
+
"gh-tool issue comments",
|
|
140
|
+
"Failed to parse response",
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
let comments = raw.map(mapRawIssueComment);
|
|
144
|
+
|
|
145
|
+
if (since !== null) {
|
|
146
|
+
const sinceMs = new Date(since).getTime();
|
|
147
|
+
comments = comments.filter((comment) => new Date(comment.createdAt).getTime() >= sinceMs);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (author !== null) {
|
|
151
|
+
const authorFilter = author.toLowerCase();
|
|
152
|
+
comments = comments.filter((comment) => comment.author.toLowerCase().includes(authorFilter));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (bodyContains !== null) {
|
|
156
|
+
const bodyFilter = bodyContains.toLowerCase();
|
|
157
|
+
comments = comments.filter((comment) => comment.body.toLowerCase().includes(bodyFilter));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return comments;
|
|
161
|
+
});
|
|
162
|
+
|
|
72
163
|
export const closeIssue = Effect.fn("issue.closeIssue")(function* (opts: {
|
|
73
164
|
issue: number;
|
|
74
165
|
comment: string | null;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export {
|
|
2
2
|
issueCloseCommand,
|
|
3
3
|
issueCommentCommand,
|
|
4
|
+
issueCommentsCommand,
|
|
4
5
|
issueEditCommand,
|
|
5
6
|
issueListCommand,
|
|
6
7
|
issueReopenCommand,
|
|
7
8
|
issueViewCommand,
|
|
8
9
|
} from "./commands";
|
|
9
|
-
export {
|
|
10
|
+
export { issueTriageCommand } from "./triage";
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { Command, Flag } from "effect/unstable/cli";
|
|
2
|
-
import { Effect } from "effect";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
import type { IssueComment } from "#gh/types";
|
|
3
5
|
|
|
4
6
|
import { formatOption, logFormatted } from "#shared";
|
|
5
7
|
import { GitHubService } from "#gh/service";
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
import { fetchIssueComments } from "./core";
|
|
10
|
+
|
|
11
|
+
const TriageVerbosity = Schema.Literals(["compact", "full"]);
|
|
12
|
+
type TriageVerbosity = typeof TriageVerbosity.Type;
|
|
10
13
|
|
|
11
14
|
type RawTriageIssue = {
|
|
12
15
|
number: number;
|
|
@@ -17,304 +20,119 @@ type RawTriageIssue = {
|
|
|
17
20
|
assignees: Array<{ login: string }>;
|
|
18
21
|
author: { login: string };
|
|
19
22
|
body: string;
|
|
20
|
-
comments: Array<unknown>;
|
|
21
23
|
createdAt: string;
|
|
24
|
+
closedAt: string | null;
|
|
22
25
|
};
|
|
23
26
|
|
|
24
|
-
type
|
|
27
|
+
type TriageIssue = {
|
|
25
28
|
number: number;
|
|
26
29
|
title: string;
|
|
27
30
|
state: string;
|
|
28
31
|
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
32
|
labels: string[];
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
body: string;
|
|
65
|
-
commentsCount: number;
|
|
33
|
+
assignees: string[];
|
|
34
|
+
author: string;
|
|
66
35
|
createdAt: string;
|
|
67
|
-
|
|
36
|
+
closedAt: string | null;
|
|
68
37
|
};
|
|
69
38
|
|
|
70
|
-
type
|
|
71
|
-
|
|
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;
|
|
39
|
+
type CompactIssueTriage = {
|
|
40
|
+
issue: TriageIssue;
|
|
83
41
|
body: string;
|
|
84
|
-
|
|
42
|
+
commentsCount: number;
|
|
43
|
+
latestComment: IssueComment | null;
|
|
85
44
|
};
|
|
86
45
|
|
|
87
|
-
type
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
summary: {
|
|
93
|
-
totalIssues: number;
|
|
94
|
-
totalPRs: number;
|
|
95
|
-
issuesByType: Record<string, number>;
|
|
96
|
-
prsByType: Record<string, number>;
|
|
97
|
-
};
|
|
46
|
+
type FullIssueTriage = {
|
|
47
|
+
issue: TriageIssue;
|
|
48
|
+
body: string;
|
|
49
|
+
commentsCount: number;
|
|
50
|
+
comments: IssueComment[];
|
|
98
51
|
};
|
|
99
52
|
|
|
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
53
|
function truncateBody(body: string, maxLength = 500): string {
|
|
183
54
|
if (body.length <= maxLength) return body;
|
|
184
55
|
return body.slice(0, maxLength) + "…";
|
|
185
56
|
}
|
|
186
57
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
58
|
+
function getLatestComment(comments: IssueComment[]): IssueComment | null {
|
|
59
|
+
if (comments.length === 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
190
62
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
63
|
+
return comments.reduce((current, next) =>
|
|
64
|
+
new Date(next.createdAt).getTime() > new Date(current.createdAt).getTime() ? next : current,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const fetchIssueTriage = Effect.fn("issue.fetchIssueTriage")(function* (opts: {
|
|
69
|
+
issue: number;
|
|
70
|
+
verbosity: TriageVerbosity;
|
|
194
71
|
}) {
|
|
195
72
|
const gh = yield* GitHubService;
|
|
196
|
-
const repoInfo = yield* gh.getRepoInfo();
|
|
197
73
|
|
|
198
|
-
|
|
199
|
-
const [rawIssues, rawPRs] = yield* Effect.all(
|
|
74
|
+
const [rawIssue, comments] = yield* Effect.all(
|
|
200
75
|
[
|
|
201
|
-
gh.runGhJson<RawTriageIssue
|
|
76
|
+
gh.runGhJson<RawTriageIssue>([
|
|
202
77
|
"issue",
|
|
203
|
-
"
|
|
204
|
-
|
|
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),
|
|
78
|
+
"view",
|
|
79
|
+
String(opts.issue),
|
|
218
80
|
"--json",
|
|
219
|
-
"number,title,state,url,labels,author,body,
|
|
81
|
+
"number,title,state,url,labels,assignees,author,body,createdAt,closedAt",
|
|
220
82
|
]),
|
|
83
|
+
fetchIssueComments(opts.issue, null, null, null),
|
|
221
84
|
],
|
|
222
85
|
{ concurrency: "unbounded" },
|
|
223
86
|
);
|
|
224
87
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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);
|
|
88
|
+
const issue: TriageIssue = {
|
|
89
|
+
number: rawIssue.number,
|
|
90
|
+
title: rawIssue.title,
|
|
91
|
+
state: rawIssue.state,
|
|
92
|
+
url: rawIssue.url,
|
|
93
|
+
labels: rawIssue.labels.map((label) => label.name),
|
|
94
|
+
assignees: rawIssue.assignees.map((assignee) => assignee.login),
|
|
95
|
+
author: rawIssue.author.login,
|
|
96
|
+
createdAt: rawIssue.createdAt,
|
|
97
|
+
closedAt: rawIssue.closedAt,
|
|
98
|
+
};
|
|
248
99
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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,
|
|
100
|
+
if (opts.verbosity === "full") {
|
|
101
|
+
const result: FullIssueTriage = {
|
|
102
|
+
issue,
|
|
103
|
+
body: rawIssue.body,
|
|
104
|
+
commentsCount: comments.length,
|
|
105
|
+
comments,
|
|
264
106
|
};
|
|
265
|
-
});
|
|
266
107
|
|
|
267
|
-
|
|
268
|
-
const issuesByType: Record<string, number> = {};
|
|
269
|
-
for (const issue of issues) {
|
|
270
|
-
issuesByType[issue.classification] = (issuesByType[issue.classification] ?? 0) + 1;
|
|
108
|
+
return result;
|
|
271
109
|
}
|
|
272
110
|
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
},
|
|
111
|
+
const result: CompactIssueTriage = {
|
|
112
|
+
issue,
|
|
113
|
+
body: truncateBody(rawIssue.body),
|
|
114
|
+
commentsCount: comments.length,
|
|
115
|
+
latestComment: getLatestComment(comments),
|
|
289
116
|
};
|
|
290
117
|
|
|
291
118
|
return result;
|
|
292
119
|
});
|
|
293
120
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
// ---------------------------------------------------------------------------
|
|
297
|
-
|
|
298
|
-
export const issueTriageSummaryCommand = Command.make(
|
|
299
|
-
"triage-summary",
|
|
121
|
+
export const issueTriageCommand = Command.make(
|
|
122
|
+
"triage",
|
|
300
123
|
{
|
|
301
124
|
format: formatOption,
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
Flag.
|
|
305
|
-
|
|
306
|
-
state: Flag.choice("state", ["open", "closed", "all"]).pipe(
|
|
307
|
-
Flag.withDescription("Filter by state: open, closed, all"),
|
|
308
|
-
Flag.withDefault("open"),
|
|
125
|
+
issue: Flag.integer("issue").pipe(Flag.withDescription("Issue number")),
|
|
126
|
+
verbosity: Flag.choice("verbosity", ["compact", "full"] as const).pipe(
|
|
127
|
+
Flag.withDescription("Output detail level: compact or full"),
|
|
128
|
+
Flag.withDefault("compact"),
|
|
309
129
|
),
|
|
310
130
|
},
|
|
311
|
-
({ format,
|
|
131
|
+
({ format, issue, verbosity }) =>
|
|
312
132
|
Effect.gen(function* () {
|
|
313
|
-
const
|
|
314
|
-
yield* logFormatted(
|
|
133
|
+
const result = yield* fetchIssueTriage({ issue, verbosity });
|
|
134
|
+
yield* logFormatted(result, format);
|
|
315
135
|
}),
|
|
316
136
|
).pipe(
|
|
317
|
-
Command.withDescription(
|
|
318
|
-
"Composite: fetch all issues + PRs, classify each, return structured triage summary",
|
|
319
|
-
),
|
|
137
|
+
Command.withDescription("Composite: fetch issue details and discussion comments in one call"),
|
|
320
138
|
);
|
|
@@ -184,30 +184,65 @@ function parseDescribeSections(
|
|
|
184
184
|
return sections;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
type KubectlTableResult = {
|
|
188
|
+
headers: string[];
|
|
189
|
+
rows: Record<string, string>[];
|
|
190
|
+
uniform?: Record<string, string>;
|
|
191
|
+
stripped?: string[];
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const PLACEHOLDER_VALUES = new Set(["<none>", "<pending>", "<unknown>", "<unset>", ""]);
|
|
195
|
+
const ALWAYS_STRIP_COLUMNS = new Set(["SELECTOR", "LABELS", "NODE-SELECTORS", "NODE_SELECTORS"]);
|
|
196
|
+
|
|
197
|
+
function isSelectorLikeValue(value: string): boolean {
|
|
198
|
+
if (!value.includes("=")) return false;
|
|
199
|
+
return value.includes("kubernetes.io/") || value.includes("k8s.io/") || value.includes(",");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function stripLowValueColumns(rows: Record<string, string>[]): {
|
|
203
|
+
rows: Record<string, string>[];
|
|
204
|
+
stripped: string[];
|
|
205
|
+
} {
|
|
188
206
|
if (rows.length === 0) {
|
|
189
|
-
return rows;
|
|
207
|
+
return { rows, stripped: [] };
|
|
190
208
|
}
|
|
191
209
|
|
|
192
210
|
const keys = Object.keys(rows[0] ?? {});
|
|
193
211
|
const keysToRemove = new Set<string>();
|
|
194
212
|
|
|
195
213
|
for (const key of keys) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
214
|
+
if (ALWAYS_STRIP_COLUMNS.has(key.toUpperCase())) {
|
|
215
|
+
keysToRemove.add(key);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const values = rows.map((row) => (row[key] ?? "").trim());
|
|
200
220
|
|
|
201
|
-
|
|
221
|
+
const allPlaceholder = values.every((v) => PLACEHOLDER_VALUES.has(v.toLowerCase()));
|
|
222
|
+
if (allPlaceholder) {
|
|
202
223
|
keysToRemove.add(key);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const nonEmptyValues = values.filter(
|
|
228
|
+
(v) => v.length > 0 && !PLACEHOLDER_VALUES.has(v.toLowerCase()),
|
|
229
|
+
);
|
|
230
|
+
if (nonEmptyValues.length > 0) {
|
|
231
|
+
const avgLength =
|
|
232
|
+
nonEmptyValues.reduce((sum, v) => sum + v.length, 0) / nonEmptyValues.length;
|
|
233
|
+
const labelCount = nonEmptyValues.filter(isSelectorLikeValue).length;
|
|
234
|
+
if (avgLength > 50 && labelCount / nonEmptyValues.length > 0.5) {
|
|
235
|
+
keysToRemove.add(key);
|
|
236
|
+
}
|
|
203
237
|
}
|
|
204
238
|
}
|
|
205
239
|
|
|
206
240
|
if (keysToRemove.size === 0) {
|
|
207
|
-
return rows;
|
|
241
|
+
return { rows, stripped: [] };
|
|
208
242
|
}
|
|
209
243
|
|
|
210
|
-
|
|
244
|
+
const stripped = keys.filter((k) => keysToRemove.has(k));
|
|
245
|
+
const filteredRows = rows.map((row) => {
|
|
211
246
|
const filtered: Record<string, string> = {};
|
|
212
247
|
for (const [key, value] of Object.entries(row)) {
|
|
213
248
|
if (!keysToRemove.has(key)) {
|
|
@@ -216,6 +251,47 @@ function stripLowValueColumns(rows: Record<string, string>[]): Record<string, st
|
|
|
216
251
|
}
|
|
217
252
|
return filtered;
|
|
218
253
|
});
|
|
254
|
+
|
|
255
|
+
return { rows: filteredRows, stripped };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function collapseUniformColumns(rows: Record<string, string>[]): {
|
|
259
|
+
rows: Record<string, string>[];
|
|
260
|
+
uniform: Record<string, string>;
|
|
261
|
+
} {
|
|
262
|
+
if (rows.length <= 1) {
|
|
263
|
+
return { rows, uniform: {} };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const keys = Object.keys(rows[0] ?? {});
|
|
267
|
+
const uniform: Record<string, string> = {};
|
|
268
|
+
const keysToCollapse = new Set<string>();
|
|
269
|
+
|
|
270
|
+
for (const key of keys) {
|
|
271
|
+
const values = rows.map((row) => (row[key] ?? "").trim());
|
|
272
|
+
const firstValue = values[0] ?? "";
|
|
273
|
+
|
|
274
|
+
if (firstValue.length > 0 && values.every((v) => v === firstValue)) {
|
|
275
|
+
uniform[key] = firstValue;
|
|
276
|
+
keysToCollapse.add(key);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (keysToCollapse.size === 0) {
|
|
281
|
+
return { rows, uniform: {} };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const cleanedRows = rows.map((row) => {
|
|
285
|
+
const filtered: Record<string, string> = {};
|
|
286
|
+
for (const [key, value] of Object.entries(row)) {
|
|
287
|
+
if (!keysToCollapse.has(key)) {
|
|
288
|
+
filtered[key] = value;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return filtered;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return { rows: cleanedRows, uniform };
|
|
219
295
|
}
|
|
220
296
|
|
|
221
297
|
export function transformPods(jsonOutput: string): PodSummary | string {
|
|
@@ -487,11 +563,17 @@ export function transformGenericKubectl(
|
|
|
487
563
|
parsedTable.headers.every((header) => /^[A-Z0-9_()\-/]+$/.test(header));
|
|
488
564
|
|
|
489
565
|
if (looksLikeTable) {
|
|
490
|
-
const rows = stripLowValueColumns(parsedTable.rows);
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
566
|
+
const { rows: strippedRows, stripped } = stripLowValueColumns(parsedTable.rows);
|
|
567
|
+
const { rows: finalRows, uniform } = collapseUniformColumns(strippedRows);
|
|
568
|
+
|
|
569
|
+
const remainingKeys =
|
|
570
|
+
finalRows.length > 0 ? new Set(Object.keys(finalRows[0] ?? {})) : new Set<string>();
|
|
571
|
+
const headers = parsedTable.headers.filter((h) => remainingKeys.has(h));
|
|
572
|
+
|
|
573
|
+
const result: KubectlTableResult = { headers, rows: finalRows };
|
|
574
|
+
if (Object.keys(uniform).length > 0) result.uniform = uniform;
|
|
575
|
+
if (stripped.length > 0) result.stripped = stripped;
|
|
576
|
+
return result;
|
|
495
577
|
}
|
|
496
578
|
|
|
497
579
|
if (lines.length > 50) {
|