@kurokeita/add-skill 1.14.0 → 1.16.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 +4 -4
- package/dist/bin/cli.js +8 -8
- package/dist/skills/gh-address-comments/SKILL.md +29 -0
- package/dist/skills/gh-address-comments/scripts/fetch_comments.py +237 -0
- package/dist/skills/gh-fix-ci/SKILL.md +70 -0
- package/dist/skills/gh-fix-ci/scripts/inspect_pr_checks.py +509 -0
- package/dist/skills/git-pr/SKILL.md +65 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,8 +6,8 @@ CLI to manage and install AI agent skills, agents, and workflows to various plat
|
|
|
6
6
|
|
|
7
7
|
- **List Items**: View a list of available AI skills, agents, and workflows.
|
|
8
8
|
- **Local status**: Check which items are installed locally.
|
|
9
|
-
- **Add Items**: Interactively add skills, agents, and workflows
|
|
10
|
-
- **Remove Items**: Remove locally installed items
|
|
9
|
+
- **Add Items**: Interactively add skills, agents, and workflows with live search/filtering support.
|
|
10
|
+
- **Remove Items**: Remove locally installed items with live search/filtering support.
|
|
11
11
|
- **Import**: Import items directly from GitHub URLs.
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
@@ -40,7 +40,7 @@ pnpx @kurokeita/add-skill list agent --local
|
|
|
40
40
|
|
|
41
41
|
### Add Items
|
|
42
42
|
|
|
43
|
-
Starts an interactive session to select and install items.
|
|
43
|
+
Starts an interactive session to select and install items. Supports live search/filtering as you type.
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
46
|
# Add skills
|
|
@@ -55,7 +55,7 @@ pnpx @kurokeita/add-skill add workflow
|
|
|
55
55
|
|
|
56
56
|
### Remove Items
|
|
57
57
|
|
|
58
|
-
Interactively select items and platforms to remove them from.
|
|
58
|
+
Interactively select items and platforms to remove them from. Supports live search/filtering as you type.
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
61
|
# Remove skills
|
package/dist/bin/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import pc5 from "picocolors";
|
|
|
9
9
|
import os3 from "node:os";
|
|
10
10
|
import path6 from "node:path";
|
|
11
11
|
import {
|
|
12
|
+
autocompleteMultiselect,
|
|
12
13
|
cancel,
|
|
13
14
|
confirm,
|
|
14
15
|
intro,
|
|
@@ -626,7 +627,7 @@ async function add(type, url, options) {
|
|
|
626
627
|
selectedItems = [result.skillName];
|
|
627
628
|
s2.stop(pc.green(`Fetched ${normalizedType}: ${result.skillName}`));
|
|
628
629
|
} catch (e) {
|
|
629
|
-
s2.
|
|
630
|
+
s2.error("Failed to fetch");
|
|
630
631
|
throw e;
|
|
631
632
|
}
|
|
632
633
|
} else {
|
|
@@ -646,10 +647,9 @@ async function add(type, url, options) {
|
|
|
646
647
|
currentType = void 0;
|
|
647
648
|
continue;
|
|
648
649
|
}
|
|
649
|
-
const items = await
|
|
650
|
+
const items = await autocompleteMultiselect({
|
|
650
651
|
message: `Select ${normalizedType}s to install:`,
|
|
651
|
-
options: availableItems
|
|
652
|
-
required: true
|
|
652
|
+
options: availableItems
|
|
653
653
|
});
|
|
654
654
|
if (isCancel(items)) {
|
|
655
655
|
if (isSingleShot) break;
|
|
@@ -880,7 +880,7 @@ async function importItem(type, url, options) {
|
|
|
880
880
|
isFile = result.isFile;
|
|
881
881
|
s.stop(pc2.green(`Fetched: ${itemName}`));
|
|
882
882
|
} catch (e) {
|
|
883
|
-
s.
|
|
883
|
+
s.error("Failed to fetch");
|
|
884
884
|
throw e;
|
|
885
885
|
}
|
|
886
886
|
const shouldFlatten = (normalizedType === "agent" || normalizedType === "workflow") && !isFile;
|
|
@@ -1067,6 +1067,7 @@ async function list(type, options) {
|
|
|
1067
1067
|
// src/commands/remove.ts
|
|
1068
1068
|
import path8 from "node:path";
|
|
1069
1069
|
import {
|
|
1070
|
+
autocompleteMultiselect as autocompleteMultiselect2,
|
|
1070
1071
|
confirm as confirm3,
|
|
1071
1072
|
intro as intro4,
|
|
1072
1073
|
isCancel as isCancel4,
|
|
@@ -1135,10 +1136,9 @@ async function remove(type, options) {
|
|
|
1135
1136
|
continue;
|
|
1136
1137
|
}
|
|
1137
1138
|
const sortedItems = Array.from(uniqueItems).sort();
|
|
1138
|
-
const itemSelections = await
|
|
1139
|
+
const itemSelections = await autocompleteMultiselect2({
|
|
1139
1140
|
message: `Select ${normalizedType}s to remove:`,
|
|
1140
|
-
options: sortedItems.map((item) => ({ value: item, label: item }))
|
|
1141
|
-
required: true
|
|
1141
|
+
options: sortedItems.map((item) => ({ value: item, label: item }))
|
|
1142
1142
|
});
|
|
1143
1143
|
if (isCancel4(itemSelections)) {
|
|
1144
1144
|
if (isSingleShot) break;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gh-address-comments
|
|
3
|
+
description: Help address review/issue comments on the open GitHub PR for the current branch using gh CLI; verify gh auth first and prompt the user to authenticate if not logged in.
|
|
4
|
+
metadata:
|
|
5
|
+
short-description: Address comments in a GitHub PR review
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# PR Comment Handler
|
|
9
|
+
|
|
10
|
+
Guide to find the open PR for the current branch and address its comments with gh CLI. Run all `gh` commands with elevated network access.
|
|
11
|
+
|
|
12
|
+
Prereq: ensure `gh` is authenticated (for example, run `gh auth login` once), then run `gh auth status` with escalated permissions (include workflow/repo scopes) so `gh` commands succeed. If sandboxing blocks `gh auth status`, rerun it with `sandbox_permissions=require_escalated`.
|
|
13
|
+
|
|
14
|
+
## 1) Inspect comments needing attention
|
|
15
|
+
|
|
16
|
+
- Run scripts/fetch_comments.py which will print out all the comments and review threads on the PR
|
|
17
|
+
|
|
18
|
+
## 2) Ask the user for clarification
|
|
19
|
+
|
|
20
|
+
- Number all the review threads and comments and provide a short summary of what would be required to apply a fix for it
|
|
21
|
+
- Ask the user which numbered comments should be addressed
|
|
22
|
+
|
|
23
|
+
## 3) If user chooses comments
|
|
24
|
+
|
|
25
|
+
- Apply fixes for the selected comments
|
|
26
|
+
|
|
27
|
+
Notes:
|
|
28
|
+
|
|
29
|
+
- If gh hits auth/rate issues mid-run, prompt the user to re-authenticate with `gh auth login`, then retry.
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Fetch all PR conversation comments + reviews + review threads (inline threads)
|
|
4
|
+
for the PR associated with the current git branch, by shelling out to:
|
|
5
|
+
|
|
6
|
+
gh api graphql
|
|
7
|
+
|
|
8
|
+
Requires:
|
|
9
|
+
- `gh auth login` already set up
|
|
10
|
+
- current branch has an associated (open) PR
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python fetch_comments.py > pr_comments.json
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
QUERY = """\
|
|
24
|
+
query(
|
|
25
|
+
$owner: String!,
|
|
26
|
+
$repo: String!,
|
|
27
|
+
$number: Int!,
|
|
28
|
+
$commentsCursor: String,
|
|
29
|
+
$reviewsCursor: String,
|
|
30
|
+
$threadsCursor: String
|
|
31
|
+
) {
|
|
32
|
+
repository(owner: $owner, name: $repo) {
|
|
33
|
+
pullRequest(number: $number) {
|
|
34
|
+
number
|
|
35
|
+
url
|
|
36
|
+
title
|
|
37
|
+
state
|
|
38
|
+
|
|
39
|
+
# Top-level "Conversation" comments (issue comments on the PR)
|
|
40
|
+
comments(first: 100, after: $commentsCursor) {
|
|
41
|
+
pageInfo { hasNextPage endCursor }
|
|
42
|
+
nodes {
|
|
43
|
+
id
|
|
44
|
+
body
|
|
45
|
+
createdAt
|
|
46
|
+
updatedAt
|
|
47
|
+
author { login }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Review submissions (Approve / Request changes / Comment), with body if present
|
|
52
|
+
reviews(first: 100, after: $reviewsCursor) {
|
|
53
|
+
pageInfo { hasNextPage endCursor }
|
|
54
|
+
nodes {
|
|
55
|
+
id
|
|
56
|
+
state
|
|
57
|
+
body
|
|
58
|
+
submittedAt
|
|
59
|
+
author { login }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Inline review threads (grouped), includes resolved state
|
|
64
|
+
reviewThreads(first: 100, after: $threadsCursor) {
|
|
65
|
+
pageInfo { hasNextPage endCursor }
|
|
66
|
+
nodes {
|
|
67
|
+
id
|
|
68
|
+
isResolved
|
|
69
|
+
isOutdated
|
|
70
|
+
path
|
|
71
|
+
line
|
|
72
|
+
diffSide
|
|
73
|
+
startLine
|
|
74
|
+
startDiffSide
|
|
75
|
+
originalLine
|
|
76
|
+
originalStartLine
|
|
77
|
+
resolvedBy { login }
|
|
78
|
+
comments(first: 100) {
|
|
79
|
+
nodes {
|
|
80
|
+
id
|
|
81
|
+
body
|
|
82
|
+
createdAt
|
|
83
|
+
updatedAt
|
|
84
|
+
author { login }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _run(cmd: list[str], stdin: str | None = None) -> str:
|
|
96
|
+
p = subprocess.run(cmd, input=stdin, capture_output=True, text=True)
|
|
97
|
+
if p.returncode != 0:
|
|
98
|
+
raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{p.stderr}")
|
|
99
|
+
return p.stdout
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _run_json(cmd: list[str], stdin: str | None = None) -> dict[str, Any]:
|
|
103
|
+
out = _run(cmd, stdin=stdin)
|
|
104
|
+
try:
|
|
105
|
+
return json.loads(out)
|
|
106
|
+
except json.JSONDecodeError as e:
|
|
107
|
+
raise RuntimeError(f"Failed to parse JSON from command output: {e}\nRaw:\n{out}") from e
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _ensure_gh_authenticated() -> None:
|
|
111
|
+
try:
|
|
112
|
+
_run(["gh", "auth", "status"])
|
|
113
|
+
except RuntimeError:
|
|
114
|
+
print("run `gh auth login` to authenticate the GitHub CLI", file=sys.stderr)
|
|
115
|
+
raise RuntimeError("gh auth status failed; run `gh auth login` to authenticate the GitHub CLI") from None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def gh_pr_view_json(fields: str) -> dict[str, Any]:
|
|
119
|
+
# fields is a comma-separated list like: "number,headRepositoryOwner,headRepository"
|
|
120
|
+
return _run_json(["gh", "pr", "view", "--json", fields])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_current_pr_ref() -> tuple[str, str, int]:
|
|
124
|
+
"""
|
|
125
|
+
Resolve the PR for the current branch (whatever gh considers associated).
|
|
126
|
+
Works for cross-repo PRs too, by reading head repository owner/name.
|
|
127
|
+
"""
|
|
128
|
+
pr = gh_pr_view_json("number,headRepositoryOwner,headRepository")
|
|
129
|
+
owner = pr["headRepositoryOwner"]["login"]
|
|
130
|
+
repo = pr["headRepository"]["name"]
|
|
131
|
+
number = int(pr["number"])
|
|
132
|
+
return owner, repo, number
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def gh_api_graphql(
|
|
136
|
+
owner: str,
|
|
137
|
+
repo: str,
|
|
138
|
+
number: int,
|
|
139
|
+
comments_cursor: str | None = None,
|
|
140
|
+
reviews_cursor: str | None = None,
|
|
141
|
+
threads_cursor: str | None = None,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
"""
|
|
144
|
+
Call `gh api graphql` using -F variables, avoiding JSON blobs with nulls.
|
|
145
|
+
Query is passed via stdin using query=@- to avoid shell newline/quoting issues.
|
|
146
|
+
"""
|
|
147
|
+
cmd = [
|
|
148
|
+
"gh",
|
|
149
|
+
"api",
|
|
150
|
+
"graphql",
|
|
151
|
+
"-F",
|
|
152
|
+
"query=@-",
|
|
153
|
+
"-F",
|
|
154
|
+
f"owner={owner}",
|
|
155
|
+
"-F",
|
|
156
|
+
f"repo={repo}",
|
|
157
|
+
"-F",
|
|
158
|
+
f"number={number}",
|
|
159
|
+
]
|
|
160
|
+
if comments_cursor:
|
|
161
|
+
cmd += ["-F", f"commentsCursor={comments_cursor}"]
|
|
162
|
+
if reviews_cursor:
|
|
163
|
+
cmd += ["-F", f"reviewsCursor={reviews_cursor}"]
|
|
164
|
+
if threads_cursor:
|
|
165
|
+
cmd += ["-F", f"threadsCursor={threads_cursor}"]
|
|
166
|
+
|
|
167
|
+
return _run_json(cmd, stdin=QUERY)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def fetch_all(owner: str, repo: str, number: int) -> dict[str, Any]:
|
|
171
|
+
conversation_comments: list[dict[str, Any]] = []
|
|
172
|
+
reviews: list[dict[str, Any]] = []
|
|
173
|
+
review_threads: list[dict[str, Any]] = []
|
|
174
|
+
|
|
175
|
+
comments_cursor: str | None = None
|
|
176
|
+
reviews_cursor: str | None = None
|
|
177
|
+
threads_cursor: str | None = None
|
|
178
|
+
|
|
179
|
+
pr_meta: dict[str, Any] | None = None
|
|
180
|
+
|
|
181
|
+
while True:
|
|
182
|
+
payload = gh_api_graphql(
|
|
183
|
+
owner=owner,
|
|
184
|
+
repo=repo,
|
|
185
|
+
number=number,
|
|
186
|
+
comments_cursor=comments_cursor,
|
|
187
|
+
reviews_cursor=reviews_cursor,
|
|
188
|
+
threads_cursor=threads_cursor,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if "errors" in payload and payload["errors"]:
|
|
192
|
+
raise RuntimeError(f"GitHub GraphQL errors:\n{json.dumps(payload['errors'], indent=2)}")
|
|
193
|
+
|
|
194
|
+
pr = payload["data"]["repository"]["pullRequest"]
|
|
195
|
+
if pr_meta is None:
|
|
196
|
+
pr_meta = {
|
|
197
|
+
"number": pr["number"],
|
|
198
|
+
"url": pr["url"],
|
|
199
|
+
"title": pr["title"],
|
|
200
|
+
"state": pr["state"],
|
|
201
|
+
"owner": owner,
|
|
202
|
+
"repo": repo,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
c = pr["comments"]
|
|
206
|
+
r = pr["reviews"]
|
|
207
|
+
t = pr["reviewThreads"]
|
|
208
|
+
|
|
209
|
+
conversation_comments.extend(c.get("nodes") or [])
|
|
210
|
+
reviews.extend(r.get("nodes") or [])
|
|
211
|
+
review_threads.extend(t.get("nodes") or [])
|
|
212
|
+
|
|
213
|
+
comments_cursor = c["pageInfo"]["endCursor"] if c["pageInfo"]["hasNextPage"] else None
|
|
214
|
+
reviews_cursor = r["pageInfo"]["endCursor"] if r["pageInfo"]["hasNextPage"] else None
|
|
215
|
+
threads_cursor = t["pageInfo"]["endCursor"] if t["pageInfo"]["hasNextPage"] else None
|
|
216
|
+
|
|
217
|
+
if not (comments_cursor or reviews_cursor or threads_cursor):
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
assert pr_meta is not None
|
|
221
|
+
return {
|
|
222
|
+
"pull_request": pr_meta,
|
|
223
|
+
"conversation_comments": conversation_comments,
|
|
224
|
+
"reviews": reviews,
|
|
225
|
+
"review_threads": review_threads,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def main() -> None:
|
|
230
|
+
_ensure_gh_authenticated()
|
|
231
|
+
owner, repo, number = get_current_pr_ref()
|
|
232
|
+
result = fetch_all(owner, repo, number)
|
|
233
|
+
print(json.dumps(result, indent=2))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
main()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "gh-fix-ci"
|
|
3
|
+
description: "Use when a user asks to debug or fix failing GitHub PR checks that run in GitHub Actions; use `gh` to inspect checks and logs, summarize failure context, draft a fix plan, and implement only after explicit approval. Treat external providers (for example Buildkite) as out of scope and report only the details URL."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Gh Pr Checks Plan Fix
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Use gh to locate failing PR checks, fetch GitHub Actions logs for actionable failures, summarize the failure snippet, then propose a fix plan and implement after explicit approval.
|
|
11
|
+
|
|
12
|
+
- If a plan-oriented skill (for example `create-plan`) is available, use it; otherwise draft a concise plan inline and request approval before implementing.
|
|
13
|
+
|
|
14
|
+
Prereq: authenticate with the standard GitHub CLI once (for example, run `gh auth login`), then confirm with `gh auth status` (repo + workflow scopes are typically required).
|
|
15
|
+
|
|
16
|
+
## Inputs
|
|
17
|
+
|
|
18
|
+
- `repo`: path inside the repo (default `.`)
|
|
19
|
+
- `pr`: PR number or URL (optional; defaults to current branch PR)
|
|
20
|
+
- `gh` authentication for the repo host
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
- `python "<path-to-skill>/scripts/inspect_pr_checks.py" --repo "." --pr "<number-or-url>"`
|
|
25
|
+
- Add `--json` if you want machine-friendly output for summarization.
|
|
26
|
+
|
|
27
|
+
## Workflow
|
|
28
|
+
|
|
29
|
+
1. Verify gh authentication.
|
|
30
|
+
- Run `gh auth status` in the repo.
|
|
31
|
+
- If unauthenticated, ask the user to run `gh auth login` (ensuring repo + workflow scopes) before proceeding.
|
|
32
|
+
2. Resolve the PR.
|
|
33
|
+
- Prefer the current branch PR: `gh pr view --json number,url`.
|
|
34
|
+
- If the user provides a PR number or URL, use that directly.
|
|
35
|
+
3. Inspect failing checks (GitHub Actions only).
|
|
36
|
+
- Preferred: run the bundled script (handles gh field drift and job-log fallbacks):
|
|
37
|
+
- `python "<path-to-skill>/scripts/inspect_pr_checks.py" --repo "." --pr "<number-or-url>"`
|
|
38
|
+
- Add `--json` for machine-friendly output.
|
|
39
|
+
- Manual fallback:
|
|
40
|
+
- `gh pr checks <pr> --json name,state,bucket,link,startedAt,completedAt,workflow`
|
|
41
|
+
- If a field is rejected, rerun with the available fields reported by `gh`.
|
|
42
|
+
- For each failing check, extract the run id from `detailsUrl` and run:
|
|
43
|
+
- `gh run view <run_id> --json name,workflowName,conclusion,status,url,event,headBranch,headSha`
|
|
44
|
+
- `gh run view <run_id> --log`
|
|
45
|
+
- If the run log says it is still in progress, fetch job logs directly:
|
|
46
|
+
- `gh api "/repos/<owner>/<repo>/actions/jobs/<job_id>/logs" > "<path>"`
|
|
47
|
+
4. Scope non-GitHub Actions checks.
|
|
48
|
+
- If `detailsUrl` is not a GitHub Actions run, label it as external and only report the URL.
|
|
49
|
+
- Do not attempt Buildkite or other providers; keep the workflow lean.
|
|
50
|
+
5. Summarize failures for the user.
|
|
51
|
+
- Provide the failing check name, run URL (if any), and a concise log snippet.
|
|
52
|
+
- Call out missing logs explicitly.
|
|
53
|
+
6. Create a plan.
|
|
54
|
+
- Use the `create-plan` skill to draft a concise plan and request approval.
|
|
55
|
+
7. Implement after approval.
|
|
56
|
+
- Apply the approved plan, summarize diffs/tests, and ask about opening a PR.
|
|
57
|
+
8. Recheck status.
|
|
58
|
+
- After changes, suggest re-running the relevant tests and `gh pr checks` to confirm.
|
|
59
|
+
|
|
60
|
+
## Bundled Resources
|
|
61
|
+
|
|
62
|
+
### scripts/inspect_pr_checks.py
|
|
63
|
+
|
|
64
|
+
Fetch failing PR checks, pull GitHub Actions logs, and extract a failure snippet. Exits non-zero when failures remain so it can be used in automation.
|
|
65
|
+
|
|
66
|
+
Usage examples:
|
|
67
|
+
|
|
68
|
+
- `python "<path-to-skill>/scripts/inspect_pr_checks.py" --repo "." --pr "123"`
|
|
69
|
+
- `python "<path-to-skill>/scripts/inspect_pr_checks.py" --repo "." --pr "https://github.com/org/repo/pull/123" --json`
|
|
70
|
+
- `python "<path-to-skill>/scripts/inspect_pr_checks.py" --repo "." --max-lines 200 --context 40`
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from shutil import which
|
|
11
|
+
from typing import Any, Iterable, Sequence
|
|
12
|
+
|
|
13
|
+
FAILURE_CONCLUSIONS = {
|
|
14
|
+
"failure",
|
|
15
|
+
"cancelled",
|
|
16
|
+
"timed_out",
|
|
17
|
+
"action_required",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
FAILURE_STATES = {
|
|
21
|
+
"failure",
|
|
22
|
+
"error",
|
|
23
|
+
"cancelled",
|
|
24
|
+
"timed_out",
|
|
25
|
+
"action_required",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
FAILURE_BUCKETS = {"fail"}
|
|
29
|
+
|
|
30
|
+
FAILURE_MARKERS = (
|
|
31
|
+
"error",
|
|
32
|
+
"fail",
|
|
33
|
+
"failed",
|
|
34
|
+
"traceback",
|
|
35
|
+
"exception",
|
|
36
|
+
"assert",
|
|
37
|
+
"panic",
|
|
38
|
+
"fatal",
|
|
39
|
+
"timeout",
|
|
40
|
+
"segmentation fault",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
DEFAULT_MAX_LINES = 160
|
|
44
|
+
DEFAULT_CONTEXT_LINES = 30
|
|
45
|
+
PENDING_LOG_MARKERS = (
|
|
46
|
+
"still in progress",
|
|
47
|
+
"log will be available when it is complete",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GhResult:
|
|
52
|
+
def __init__(self, returncode: int, stdout: str, stderr: str):
|
|
53
|
+
self.returncode = returncode
|
|
54
|
+
self.stdout = stdout
|
|
55
|
+
self.stderr = stderr
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_gh_command(args: Sequence[str], cwd: Path) -> GhResult:
|
|
59
|
+
process = subprocess.run(
|
|
60
|
+
["gh", *args],
|
|
61
|
+
cwd=cwd,
|
|
62
|
+
text=True,
|
|
63
|
+
capture_output=True,
|
|
64
|
+
)
|
|
65
|
+
return GhResult(process.returncode, process.stdout, process.stderr)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_gh_command_raw(args: Sequence[str], cwd: Path) -> tuple[int, bytes, str]:
|
|
69
|
+
process = subprocess.run(
|
|
70
|
+
["gh", *args],
|
|
71
|
+
cwd=cwd,
|
|
72
|
+
capture_output=True,
|
|
73
|
+
)
|
|
74
|
+
stderr = process.stderr.decode(errors="replace")
|
|
75
|
+
return process.returncode, process.stdout, stderr
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_args() -> argparse.Namespace:
|
|
79
|
+
parser = argparse.ArgumentParser(
|
|
80
|
+
description=(
|
|
81
|
+
"Inspect failing GitHub PR checks, fetch GitHub Actions logs, and extract a "
|
|
82
|
+
"failure snippet."
|
|
83
|
+
),
|
|
84
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument("--repo", default=".", help="Path inside the target Git repository.")
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--pr", default=None, help="PR number or URL (defaults to current branch PR)."
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument("--max-lines", type=int, default=DEFAULT_MAX_LINES)
|
|
91
|
+
parser.add_argument("--context", type=int, default=DEFAULT_CONTEXT_LINES)
|
|
92
|
+
parser.add_argument("--json", action="store_true", help="Emit JSON instead of text output.")
|
|
93
|
+
return parser.parse_args()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main() -> int:
|
|
97
|
+
args = parse_args()
|
|
98
|
+
repo_root = find_git_root(Path(args.repo))
|
|
99
|
+
if repo_root is None:
|
|
100
|
+
print("Error: not inside a Git repository.", file=sys.stderr)
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
if not ensure_gh_available(repo_root):
|
|
104
|
+
return 1
|
|
105
|
+
|
|
106
|
+
pr_value = resolve_pr(args.pr, repo_root)
|
|
107
|
+
if pr_value is None:
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
checks = fetch_checks(pr_value, repo_root)
|
|
111
|
+
if checks is None:
|
|
112
|
+
return 1
|
|
113
|
+
|
|
114
|
+
failing = [c for c in checks if is_failing(c)]
|
|
115
|
+
if not failing:
|
|
116
|
+
print(f"PR #{pr_value}: no failing checks detected.")
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
results = []
|
|
120
|
+
for check in failing:
|
|
121
|
+
results.append(
|
|
122
|
+
analyze_check(
|
|
123
|
+
check,
|
|
124
|
+
repo_root=repo_root,
|
|
125
|
+
max_lines=max(1, args.max_lines),
|
|
126
|
+
context=max(1, args.context),
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if args.json:
|
|
131
|
+
print(json.dumps({"pr": pr_value, "results": results}, indent=2))
|
|
132
|
+
else:
|
|
133
|
+
render_results(pr_value, results)
|
|
134
|
+
|
|
135
|
+
return 1
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def find_git_root(start: Path) -> Path | None:
|
|
139
|
+
result = subprocess.run(
|
|
140
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
141
|
+
cwd=start,
|
|
142
|
+
text=True,
|
|
143
|
+
capture_output=True,
|
|
144
|
+
)
|
|
145
|
+
if result.returncode != 0:
|
|
146
|
+
return None
|
|
147
|
+
return Path(result.stdout.strip())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ensure_gh_available(repo_root: Path) -> bool:
|
|
151
|
+
if which("gh") is None:
|
|
152
|
+
print("Error: gh is not installed or not on PATH.", file=sys.stderr)
|
|
153
|
+
return False
|
|
154
|
+
result = run_gh_command(["auth", "status"], cwd=repo_root)
|
|
155
|
+
if result.returncode == 0:
|
|
156
|
+
return True
|
|
157
|
+
message = (result.stderr or result.stdout or "").strip()
|
|
158
|
+
print(message or "Error: gh not authenticated.", file=sys.stderr)
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def resolve_pr(pr_value: str | None, repo_root: Path) -> str | None:
|
|
163
|
+
if pr_value:
|
|
164
|
+
return pr_value
|
|
165
|
+
result = run_gh_command(["pr", "view", "--json", "number"], cwd=repo_root)
|
|
166
|
+
if result.returncode != 0:
|
|
167
|
+
message = (result.stderr or result.stdout or "").strip()
|
|
168
|
+
print(message or "Error: unable to resolve PR.", file=sys.stderr)
|
|
169
|
+
return None
|
|
170
|
+
try:
|
|
171
|
+
data = json.loads(result.stdout or "{}")
|
|
172
|
+
except json.JSONDecodeError:
|
|
173
|
+
print("Error: unable to parse PR JSON.", file=sys.stderr)
|
|
174
|
+
return None
|
|
175
|
+
number = data.get("number")
|
|
176
|
+
if not number:
|
|
177
|
+
print("Error: no PR number found.", file=sys.stderr)
|
|
178
|
+
return None
|
|
179
|
+
return str(number)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def fetch_checks(pr_value: str, repo_root: Path) -> list[dict[str, Any]] | None:
|
|
183
|
+
primary_fields = ["name", "state", "conclusion", "detailsUrl", "startedAt", "completedAt"]
|
|
184
|
+
result = run_gh_command(
|
|
185
|
+
["pr", "checks", pr_value, "--json", ",".join(primary_fields)],
|
|
186
|
+
cwd=repo_root,
|
|
187
|
+
)
|
|
188
|
+
if result.returncode != 0:
|
|
189
|
+
message = "\n".join(filter(None, [result.stderr, result.stdout])).strip()
|
|
190
|
+
available_fields = parse_available_fields(message)
|
|
191
|
+
if available_fields:
|
|
192
|
+
fallback_fields = [
|
|
193
|
+
"name",
|
|
194
|
+
"state",
|
|
195
|
+
"bucket",
|
|
196
|
+
"link",
|
|
197
|
+
"startedAt",
|
|
198
|
+
"completedAt",
|
|
199
|
+
"workflow",
|
|
200
|
+
]
|
|
201
|
+
selected_fields = [field for field in fallback_fields if field in available_fields]
|
|
202
|
+
if not selected_fields:
|
|
203
|
+
print("Error: no usable fields available for gh pr checks.", file=sys.stderr)
|
|
204
|
+
return None
|
|
205
|
+
result = run_gh_command(
|
|
206
|
+
["pr", "checks", pr_value, "--json", ",".join(selected_fields)],
|
|
207
|
+
cwd=repo_root,
|
|
208
|
+
)
|
|
209
|
+
if result.returncode != 0:
|
|
210
|
+
message = (result.stderr or result.stdout or "").strip()
|
|
211
|
+
print(message or "Error: gh pr checks failed.", file=sys.stderr)
|
|
212
|
+
return None
|
|
213
|
+
else:
|
|
214
|
+
print(message or "Error: gh pr checks failed.", file=sys.stderr)
|
|
215
|
+
return None
|
|
216
|
+
try:
|
|
217
|
+
data = json.loads(result.stdout or "[]")
|
|
218
|
+
except json.JSONDecodeError:
|
|
219
|
+
print("Error: unable to parse checks JSON.", file=sys.stderr)
|
|
220
|
+
return None
|
|
221
|
+
if not isinstance(data, list):
|
|
222
|
+
print("Error: unexpected checks JSON shape.", file=sys.stderr)
|
|
223
|
+
return None
|
|
224
|
+
return data
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def is_failing(check: dict[str, Any]) -> bool:
|
|
228
|
+
conclusion = normalize_field(check.get("conclusion"))
|
|
229
|
+
if conclusion in FAILURE_CONCLUSIONS:
|
|
230
|
+
return True
|
|
231
|
+
state = normalize_field(check.get("state") or check.get("status"))
|
|
232
|
+
if state in FAILURE_STATES:
|
|
233
|
+
return True
|
|
234
|
+
bucket = normalize_field(check.get("bucket"))
|
|
235
|
+
return bucket in FAILURE_BUCKETS
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def analyze_check(
|
|
239
|
+
check: dict[str, Any],
|
|
240
|
+
repo_root: Path,
|
|
241
|
+
max_lines: int,
|
|
242
|
+
context: int,
|
|
243
|
+
) -> dict[str, Any]:
|
|
244
|
+
url = check.get("detailsUrl") or check.get("link") or ""
|
|
245
|
+
run_id = extract_run_id(url)
|
|
246
|
+
job_id = extract_job_id(url)
|
|
247
|
+
base: dict[str, Any] = {
|
|
248
|
+
"name": check.get("name", ""),
|
|
249
|
+
"detailsUrl": url,
|
|
250
|
+
"runId": run_id,
|
|
251
|
+
"jobId": job_id,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if run_id is None:
|
|
255
|
+
base["status"] = "external"
|
|
256
|
+
base["note"] = "No GitHub Actions run id detected in detailsUrl."
|
|
257
|
+
return base
|
|
258
|
+
|
|
259
|
+
metadata = fetch_run_metadata(run_id, repo_root)
|
|
260
|
+
log_text, log_error, log_status = fetch_check_log(
|
|
261
|
+
run_id=run_id,
|
|
262
|
+
job_id=job_id,
|
|
263
|
+
repo_root=repo_root,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if log_status == "pending":
|
|
267
|
+
base["status"] = "log_pending"
|
|
268
|
+
base["note"] = log_error or "Logs are not available yet."
|
|
269
|
+
if metadata:
|
|
270
|
+
base["run"] = metadata
|
|
271
|
+
return base
|
|
272
|
+
|
|
273
|
+
if log_error:
|
|
274
|
+
base["status"] = "log_unavailable"
|
|
275
|
+
base["error"] = log_error
|
|
276
|
+
if metadata:
|
|
277
|
+
base["run"] = metadata
|
|
278
|
+
return base
|
|
279
|
+
|
|
280
|
+
snippet = extract_failure_snippet(log_text, max_lines=max_lines, context=context)
|
|
281
|
+
base["status"] = "ok"
|
|
282
|
+
base["run"] = metadata or {}
|
|
283
|
+
base["logSnippet"] = snippet
|
|
284
|
+
base["logTail"] = tail_lines(log_text, max_lines)
|
|
285
|
+
return base
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def extract_run_id(url: str) -> str | None:
|
|
289
|
+
if not url:
|
|
290
|
+
return None
|
|
291
|
+
for pattern in (r"/actions/runs/(\d+)", r"/runs/(\d+)"):
|
|
292
|
+
match = re.search(pattern, url)
|
|
293
|
+
if match:
|
|
294
|
+
return match.group(1)
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def extract_job_id(url: str) -> str | None:
|
|
299
|
+
if not url:
|
|
300
|
+
return None
|
|
301
|
+
match = re.search(r"/actions/runs/\d+/job/(\d+)", url)
|
|
302
|
+
if match:
|
|
303
|
+
return match.group(1)
|
|
304
|
+
match = re.search(r"/job/(\d+)", url)
|
|
305
|
+
if match:
|
|
306
|
+
return match.group(1)
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def fetch_run_metadata(run_id: str, repo_root: Path) -> dict[str, Any] | None:
|
|
311
|
+
fields = [
|
|
312
|
+
"conclusion",
|
|
313
|
+
"status",
|
|
314
|
+
"workflowName",
|
|
315
|
+
"name",
|
|
316
|
+
"event",
|
|
317
|
+
"headBranch",
|
|
318
|
+
"headSha",
|
|
319
|
+
"url",
|
|
320
|
+
]
|
|
321
|
+
result = run_gh_command(["run", "view", run_id, "--json", ",".join(fields)], cwd=repo_root)
|
|
322
|
+
if result.returncode != 0:
|
|
323
|
+
return None
|
|
324
|
+
try:
|
|
325
|
+
data = json.loads(result.stdout or "{}")
|
|
326
|
+
except json.JSONDecodeError:
|
|
327
|
+
return None
|
|
328
|
+
if not isinstance(data, dict):
|
|
329
|
+
return None
|
|
330
|
+
return data
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def fetch_check_log(
|
|
334
|
+
run_id: str,
|
|
335
|
+
job_id: str | None,
|
|
336
|
+
repo_root: Path,
|
|
337
|
+
) -> tuple[str, str, str]:
|
|
338
|
+
log_text, log_error = fetch_run_log(run_id, repo_root)
|
|
339
|
+
if not log_error:
|
|
340
|
+
return log_text, "", "ok"
|
|
341
|
+
|
|
342
|
+
if is_log_pending_message(log_error) and job_id:
|
|
343
|
+
job_log, job_error = fetch_job_log(job_id, repo_root)
|
|
344
|
+
if job_log:
|
|
345
|
+
return job_log, "", "ok"
|
|
346
|
+
if job_error and is_log_pending_message(job_error):
|
|
347
|
+
return "", job_error, "pending"
|
|
348
|
+
if job_error:
|
|
349
|
+
return "", job_error, "error"
|
|
350
|
+
return "", log_error, "pending"
|
|
351
|
+
|
|
352
|
+
if is_log_pending_message(log_error):
|
|
353
|
+
return "", log_error, "pending"
|
|
354
|
+
|
|
355
|
+
return "", log_error, "error"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def fetch_run_log(run_id: str, repo_root: Path) -> tuple[str, str]:
|
|
359
|
+
result = run_gh_command(["run", "view", run_id, "--log"], cwd=repo_root)
|
|
360
|
+
if result.returncode != 0:
|
|
361
|
+
error = (result.stderr or result.stdout or "").strip()
|
|
362
|
+
return "", error or "gh run view failed"
|
|
363
|
+
return result.stdout, ""
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def fetch_job_log(job_id: str, repo_root: Path) -> tuple[str, str]:
|
|
367
|
+
repo_slug = fetch_repo_slug(repo_root)
|
|
368
|
+
if not repo_slug:
|
|
369
|
+
return "", "Error: unable to resolve repository name for job logs."
|
|
370
|
+
endpoint = f"/repos/{repo_slug}/actions/jobs/{job_id}/logs"
|
|
371
|
+
returncode, stdout_bytes, stderr = run_gh_command_raw(["api", endpoint], cwd=repo_root)
|
|
372
|
+
if returncode != 0:
|
|
373
|
+
message = (stderr or stdout_bytes.decode(errors="replace")).strip()
|
|
374
|
+
return "", message or "gh api job logs failed"
|
|
375
|
+
if is_zip_payload(stdout_bytes):
|
|
376
|
+
return "", "Job logs returned a zip archive; unable to parse."
|
|
377
|
+
return stdout_bytes.decode(errors="replace"), ""
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def fetch_repo_slug(repo_root: Path) -> str | None:
|
|
381
|
+
result = run_gh_command(["repo", "view", "--json", "nameWithOwner"], cwd=repo_root)
|
|
382
|
+
if result.returncode != 0:
|
|
383
|
+
return None
|
|
384
|
+
try:
|
|
385
|
+
data = json.loads(result.stdout or "{}")
|
|
386
|
+
except json.JSONDecodeError:
|
|
387
|
+
return None
|
|
388
|
+
name_with_owner = data.get("nameWithOwner")
|
|
389
|
+
if not name_with_owner:
|
|
390
|
+
return None
|
|
391
|
+
return str(name_with_owner)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def normalize_field(value: Any) -> str:
|
|
395
|
+
if value is None:
|
|
396
|
+
return ""
|
|
397
|
+
return str(value).strip().lower()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def parse_available_fields(message: str) -> list[str]:
|
|
401
|
+
if "Available fields:" not in message:
|
|
402
|
+
return []
|
|
403
|
+
fields: list[str] = []
|
|
404
|
+
collecting = False
|
|
405
|
+
for line in message.splitlines():
|
|
406
|
+
if "Available fields:" in line:
|
|
407
|
+
collecting = True
|
|
408
|
+
continue
|
|
409
|
+
if not collecting:
|
|
410
|
+
continue
|
|
411
|
+
field = line.strip()
|
|
412
|
+
if not field:
|
|
413
|
+
continue
|
|
414
|
+
fields.append(field)
|
|
415
|
+
return fields
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def is_log_pending_message(message: str) -> bool:
|
|
419
|
+
lowered = message.lower()
|
|
420
|
+
return any(marker in lowered for marker in PENDING_LOG_MARKERS)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def is_zip_payload(payload: bytes) -> bool:
|
|
424
|
+
return payload.startswith(b"PK")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def extract_failure_snippet(log_text: str, max_lines: int, context: int) -> str:
|
|
428
|
+
lines = log_text.splitlines()
|
|
429
|
+
if not lines:
|
|
430
|
+
return ""
|
|
431
|
+
|
|
432
|
+
marker_index = find_failure_index(lines)
|
|
433
|
+
if marker_index is None:
|
|
434
|
+
return "\n".join(lines[-max_lines:])
|
|
435
|
+
|
|
436
|
+
start = max(0, marker_index - context)
|
|
437
|
+
end = min(len(lines), marker_index + context)
|
|
438
|
+
window = lines[start:end]
|
|
439
|
+
if len(window) > max_lines:
|
|
440
|
+
window = window[-max_lines:]
|
|
441
|
+
return "\n".join(window)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def find_failure_index(lines: Sequence[str]) -> int | None:
|
|
445
|
+
for idx in range(len(lines) - 1, -1, -1):
|
|
446
|
+
lowered = lines[idx].lower()
|
|
447
|
+
if any(marker in lowered for marker in FAILURE_MARKERS):
|
|
448
|
+
return idx
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def tail_lines(text: str, max_lines: int) -> str:
|
|
453
|
+
if max_lines <= 0:
|
|
454
|
+
return ""
|
|
455
|
+
lines = text.splitlines()
|
|
456
|
+
return "\n".join(lines[-max_lines:])
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def render_results(pr_number: str, results: Iterable[dict[str, Any]]) -> None:
|
|
460
|
+
results_list = list(results)
|
|
461
|
+
print(f"PR #{pr_number}: {len(results_list)} failing checks analyzed.")
|
|
462
|
+
for result in results_list:
|
|
463
|
+
print("-" * 60)
|
|
464
|
+
print(f"Check: {result.get('name', '')}")
|
|
465
|
+
if result.get("detailsUrl"):
|
|
466
|
+
print(f"Details: {result['detailsUrl']}")
|
|
467
|
+
run_id = result.get("runId")
|
|
468
|
+
if run_id:
|
|
469
|
+
print(f"Run ID: {run_id}")
|
|
470
|
+
job_id = result.get("jobId")
|
|
471
|
+
if job_id:
|
|
472
|
+
print(f"Job ID: {job_id}")
|
|
473
|
+
status = result.get("status", "unknown")
|
|
474
|
+
print(f"Status: {status}")
|
|
475
|
+
|
|
476
|
+
run_meta = result.get("run", {})
|
|
477
|
+
if run_meta:
|
|
478
|
+
branch = run_meta.get("headBranch", "")
|
|
479
|
+
sha = (run_meta.get("headSha") or "")[:12]
|
|
480
|
+
workflow = run_meta.get("workflowName") or run_meta.get("name") or ""
|
|
481
|
+
conclusion = run_meta.get("conclusion") or run_meta.get("status") or ""
|
|
482
|
+
print(f"Workflow: {workflow} ({conclusion})")
|
|
483
|
+
if branch or sha:
|
|
484
|
+
print(f"Branch/SHA: {branch} {sha}")
|
|
485
|
+
if run_meta.get("url"):
|
|
486
|
+
print(f"Run URL: {run_meta['url']}")
|
|
487
|
+
|
|
488
|
+
if result.get("note"):
|
|
489
|
+
print(f"Note: {result['note']}")
|
|
490
|
+
|
|
491
|
+
if result.get("error"):
|
|
492
|
+
print(f"Error fetching logs: {result['error']}")
|
|
493
|
+
continue
|
|
494
|
+
|
|
495
|
+
snippet = result.get("logSnippet") or ""
|
|
496
|
+
if snippet:
|
|
497
|
+
print("Failure snippet:")
|
|
498
|
+
print(indent_block(snippet, prefix=" "))
|
|
499
|
+
else:
|
|
500
|
+
print("No snippet available.")
|
|
501
|
+
print("-" * 60)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def indent_block(text: str, prefix: str = " ") -> str:
|
|
505
|
+
return "\n".join(f"{prefix}{line}" for line in text.splitlines())
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
if __name__ == "__main__":
|
|
509
|
+
raise SystemExit(main())
|
|
@@ -30,6 +30,71 @@ Create clear and informative pull requests to ensure that reviewers can understa
|
|
|
30
30
|
- How they were tested.
|
|
31
31
|
- Any breaking changes or new dependencies.
|
|
32
32
|
|
|
33
|
+
### Merge Strategy
|
|
34
|
+
|
|
35
|
+
This repo uses **squash merge** for all PRs. The resulting commit on `main` follows a strict format:
|
|
36
|
+
|
|
37
|
+
- **Commit title:** `<conventional-commit-title> (#<pr-number>)`
|
|
38
|
+
- Example: `feat(cli): implement live search filtering for item selection (#24)`
|
|
39
|
+
- **Extended description:** A concise summary paragraph followed by bullet-point details of key changes.
|
|
40
|
+
- The PR description body becomes the commit message body during squash merge.
|
|
41
|
+
- Write the description so it reads well as a git log entry — focus on **what** changed and **why**, not **how**.
|
|
42
|
+
- Use bullet points for listing specific file-level or feature-level changes.
|
|
43
|
+
- Avoid checklist boilerplate in the final description; it clutters the git history.
|
|
44
|
+
|
|
45
|
+
#### Commit Message Examples
|
|
46
|
+
|
|
47
|
+
**Complex feature** — Summary paragraph + "Key changes:" section:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
feat(scope): add feature description (#N)
|
|
51
|
+
|
|
52
|
+
One or two sentences describing what changed and why it matters. Focus on
|
|
53
|
+
the user-facing impact or developer experience improvement.
|
|
54
|
+
|
|
55
|
+
Key changes:
|
|
56
|
+
- Updated dependency X to version Y for improved performance.
|
|
57
|
+
- Refactored module A to support the new behavior.
|
|
58
|
+
- Added tests covering edge cases in the new logic.
|
|
59
|
+
- Updated documentation to reflect the new capability.
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Multi-part refactor** — Summary paragraph + plain bullets:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
refactor(scope): describe the refactoring goal
|
|
66
|
+
|
|
67
|
+
Brief explanation of the refactoring goal and what it improves
|
|
68
|
+
(maintainability, performance, clarity, etc.).
|
|
69
|
+
|
|
70
|
+
- Split monolithic module X into focused submodules A, B, and C.
|
|
71
|
+
- Module A: handles responsibility 1 with clear public API.
|
|
72
|
+
- Module B: handles responsibility 2, extracted for reuse.
|
|
73
|
+
- Ensured all modules follow progressive disclosure principles.
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Architecture change** — Bold-prefixed bullets for scannability:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
feat: describe the architectural change (#N)
|
|
80
|
+
|
|
81
|
+
- **Component A**: Implemented the new subsystem for handling X.
|
|
82
|
+
- **Component B**: Added transformation logic between formats Y and Z.
|
|
83
|
+
- **Unified Entry**: Refactored the main entry point to support both
|
|
84
|
+
interactive and programmatic usage.
|
|
85
|
+
- **Test Coverage**: Achieved and verified full coverage for all modules.
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Simple change** — Minimal bullets, no summary paragraph:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
feat(scope): add or update something
|
|
92
|
+
|
|
93
|
+
- Add configuration file with default settings
|
|
94
|
+
- Add reference documentation for the new feature
|
|
95
|
+
- Add example usage in the project README
|
|
96
|
+
```
|
|
97
|
+
|
|
33
98
|
### Best Practices
|
|
34
99
|
|
|
35
100
|
- **Keep it focused**: One PR per logical change. Avoid "mega-PRs".
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kurokeita/add-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"description": "CLI to install AI agent skills to various platforms",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"license": "GPL-3.0",
|
|
38
38
|
"packageManager": "pnpm@10.28.0",
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@clack/prompts": "^
|
|
40
|
+
"@clack/prompts": "^1.2.0",
|
|
41
41
|
"commander": "^14.0.2",
|
|
42
42
|
"fs-extra": "^11.3.3",
|
|
43
43
|
"js-yaml": "^4.1.1",
|