Skip to content

Commit 8bc8c1b

Browse files
committed
Add jobs retry command with optional scope diagnostics
1 parent 55fec29 commit 8bc8c1b

10 files changed

Lines changed: 404 additions & 17 deletions

File tree

API.md

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,10 @@ Required scopes for this CLI:
160160
- `read_build_logs`
161161
- `read_artifacts`
162162

163+
Optional capability scopes:
164+
165+
- `write_builds` (needed for `jobs.retry`)
166+
163167
### Success response
164168

165169
```json
@@ -176,7 +180,8 @@ Required scopes for this CLI:
176180
],
177181
"grantedScopes": 3,
178182
"missingScopes": [],
179-
"ready": true
183+
"ready": true,
184+
"warnings": []
180185
},
181186
"pagination": null,
182187
"data": {
@@ -199,7 +204,16 @@ Required scopes for this CLI:
199204
"read_build_logs",
200205
"read_artifacts"
201206
],
202-
"missingScopes": []
207+
"missingScopes": [],
208+
"capabilities": {
209+
"jobsRetry": {
210+
"requiredScopes": [
211+
"write_builds"
212+
],
213+
"missingScopes": [],
214+
"ready": true
215+
}
216+
}
203217
},
204218
"error": null
205219
}
@@ -384,6 +398,57 @@ Use `--raw` to keep exact Buildkite payloads.
384398
}
385399
```
386400

401+
## `jobs.retry`
402+
403+
Retry a failed/timed-out job.
404+
405+
Requires `write_builds` scope.
406+
407+
### Request
408+
409+
```json
410+
{
411+
"org": "acme",
412+
"pipeline": "web",
413+
"buildNumber": 942,
414+
"jobId": "0197abcd"
415+
}
416+
```
417+
418+
### Success response
419+
420+
```json
421+
{
422+
"ok": true,
423+
"apiVersion": "v1",
424+
"command": "jobs.retry",
425+
"request": {
426+
"org": "acme",
427+
"pipeline": "web",
428+
"buildNumber": 942,
429+
"jobId": "0197abcd"
430+
},
431+
"summary": {
432+
"retried": true,
433+
"jobId": "0197efgh",
434+
"state": "scheduled"
435+
},
436+
"pagination": null,
437+
"data": {
438+
"job": {
439+
"id": "0197efgh",
440+
"type": "script",
441+
"name": "Playwright tests",
442+
"stepKey": "e2e",
443+
"state": "scheduled",
444+
"exitStatus": null,
445+
"webUrl": "https://buildkite.com/acme/web/builds/942#job-0197efgh"
446+
}
447+
},
448+
"error": null
449+
}
450+
```
451+
387452
## `artifacts.list`
388453

389454
List artifacts for a build, optionally filtered by job.
@@ -541,6 +606,7 @@ List build annotations.
541606
- `builds.list` -> `GET /v2/builds` or `GET /v2/organizations/{org}/builds` or `GET /v2/organizations/{org}/pipelines/{pipeline}/builds`
542607
- `builds.get` -> `GET /v2/organizations/{org}/pipelines/{pipeline}/builds/{number}`
543608
- `jobs.log.get` -> `GET /v2/organizations/{org}/pipelines/{pipeline}/builds/{number}/jobs/{job.id}/log`
609+
- `jobs.retry` -> `PUT /v2/organizations/{org}/pipelines/{pipeline}/builds/{number}/jobs/{job.id}/retry`
544610
- `artifacts.list` -> `GET /v2/organizations/{org}/pipelines/{pipeline}/builds/{number}/artifacts` or `GET /v2/organizations/{org}/pipelines/{pipeline}/builds/{number}/jobs/{job.id}/artifacts`
545611
- `artifacts.download` -> `GET /v2/organizations/{org}/pipelines/{pipeline}/builds/{number}/jobs/{job.id}/artifacts/{id}/download`
546612
- `annotations.list` -> `GET /v2/organizations/{org}/pipelines/{pipeline}/builds/{number}/annotations`
@@ -552,7 +618,6 @@ These are strong candidates for the next version:
552618
- `builds.create`
553619
- `builds.rebuild`
554620
- `builds.cancel`
555-
- `jobs.retry`
556621
- `jobs.unblock`
557622
- `jobs.env.get`
558623
- `pipelines.list`

README.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ A local CLI that queries Buildkite REST APIs and returns LLM-friendly JSON envel
77
- List builds.
88
- Get one build with job summary.
99
- Fetch job logs.
10+
- Retry failed/timed-out jobs.
1011
- List artifacts.
1112
- Download artifacts.
1213
- List annotations.
@@ -70,21 +71,50 @@ bkci auth status
7071
bkci builds list --org ORG [--pipeline PIPELINE] [--branch BRANCH] [--state STATE]
7172
bkci builds get --org ORG --pipeline PIPELINE --build BUILD_NUMBER
7273
bkci jobs log get --org ORG --pipeline PIPELINE --build BUILD_NUMBER --job JOB_ID
74+
bkci jobs retry --org ORG --pipeline PIPELINE --build BUILD_NUMBER --job JOB_ID
7375
bkci artifacts list --org ORG --pipeline PIPELINE --build BUILD_NUMBER [--job JOB_ID]
7476
bkci artifacts download --org ORG --pipeline PIPELINE --build BUILD_NUMBER [--job JOB_ID] [--artifact-id ID ...] [--glob GLOB] [--out DIR]
7577
bkci annotations list --org ORG --pipeline PIPELINE --build BUILD_NUMBER
7678
```
7779

7880
## Required token scopes
7981

82+
Baseline scopes:
83+
8084
- `read_builds`
8185
- `read_build_logs`
8286
- `read_artifacts`
8387

88+
Additional scope for retrying jobs:
89+
90+
- `write_builds`
91+
92+
## Retry jobs examples
93+
94+
Retry a job:
95+
96+
```bash
97+
bkci jobs retry --org acme --pipeline web --build 942 --job 0197abcd
98+
```
99+
100+
If your token is missing `write_builds`, `bkci` returns a clear error envelope (and does not retry):
101+
102+
```json
103+
{
104+
"ok": false,
105+
"command": "jobs.retry",
106+
"error": {
107+
"type": "permission_error",
108+
"message": "failed to retry job: token missing required scope(s): write_builds",
109+
"code": "missing_scope"
110+
}
111+
}
112+
```
113+
84114
## Notes
85115

86116
- Use `--raw` on any command to return raw Buildkite payloads inside the envelope.
87-
- `bkci auth status` checks token scopes and reports missing required scopes.
117+
- `bkci auth status` checks token scopes, reports missing required scopes, and warns when optional capability scopes (for example `jobs.retry`) are missing.
88118
- `jobs log get` strips ANSI/control sequences in normalized mode for cleaner LLM output.
89119
- Pagination metadata is parsed from Buildkite `Link` headers for list endpoints.
90120

src/cli/execute-command.test.ts

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ type MockClient = {
1212
readonly requestJson: (options: {
1313
readonly path: string;
1414
readonly query?: Record<string, string | number | null>;
15+
readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
16+
readonly body?: unknown;
1517
}) => Promise<BuildkiteJsonResponse>;
1618
readonly requestBinary: (options: {
1719
readonly path: string;
@@ -30,15 +32,16 @@ function createMockClient(options: {
3032

3133
return {
3234
requestJson: async (request) => {
33-
seenPaths.push(request.path);
35+
const method = request.method ?? "GET";
36+
seenPaths.push(`${method} ${request.path}`);
3437
const next = jsonQueue.shift() ?? null;
3538
if (next === null) {
3639
throw new Error("unexpected json request");
3740
}
3841
return next;
3942
},
4043
requestBinary: async (request) => {
41-
seenPaths.push(request.path);
44+
seenPaths.push(`GET ${request.path}`);
4245
const bytes = binaryByPath[request.path] ?? null;
4346
if (bytes === null) {
4447
throw new Error(`unexpected binary request: ${request.path}`);
@@ -78,10 +81,18 @@ test("executeCommand auth.status returns required scope diagnostics", async () =
7881
grantedScopes: 2,
7982
missingScopes: ["read_artifacts"],
8083
ready: false,
84+
warnings: ["jobs.retry requires token scope: write_builds"],
8185
});
8286

8387
const data = result.data as Record<string, unknown>;
8488
assert.deepEqual(data.missingScopes, ["read_artifacts"]);
89+
assert.deepEqual(data.capabilities, {
90+
jobsRetry: {
91+
requiredScopes: ["write_builds"],
92+
missingScopes: ["write_builds"],
93+
ready: false,
94+
},
95+
});
8596
});
8697

8798
test("executeCommand builds.list normalizes data and pagination", async () => {
@@ -188,6 +199,105 @@ test("executeCommand builds.get computes failed job ids", async () => {
188199
assert.deepEqual(summary.failedJobIds, ["job-1"]);
189200
});
190201

202+
test("executeCommand jobs.retry retries job when write_builds scope is present", async () => {
203+
const command = parseCliArgs([
204+
"jobs",
205+
"retry",
206+
"--org",
207+
"acme",
208+
"--pipeline",
209+
"web",
210+
"--build",
211+
"77",
212+
"--job",
213+
"job-1",
214+
]);
215+
216+
const seenPaths: Array<string> = [];
217+
const client = createMockClient({
218+
seenPaths,
219+
jsonResponses: [
220+
{
221+
status: 200,
222+
headers: new Headers(),
223+
requestId: "req-auth",
224+
data: {
225+
scopes: ["read_builds", "read_build_logs", "read_artifacts", "write_builds"],
226+
},
227+
},
228+
{
229+
status: 200,
230+
headers: new Headers(),
231+
requestId: "req-retry",
232+
data: {
233+
id: "job-2",
234+
state: "scheduled",
235+
name: "tests",
236+
type: "script",
237+
},
238+
},
239+
],
240+
});
241+
242+
const result = await executeCommand({ command, client });
243+
244+
assert.deepEqual(seenPaths, [
245+
"GET /v2/access-token",
246+
"PUT /v2/organizations/acme/pipelines/web/builds/77/jobs/job-1/retry",
247+
]);
248+
249+
assert.deepEqual(result.summary, {
250+
retried: true,
251+
jobId: "job-2",
252+
state: "scheduled",
253+
});
254+
255+
assert.deepEqual(result.data, {
256+
job: {
257+
id: "job-2",
258+
type: "script",
259+
name: "tests",
260+
stepKey: null,
261+
state: "scheduled",
262+
exitStatus: null,
263+
webUrl: null,
264+
},
265+
});
266+
});
267+
268+
test("executeCommand jobs.retry returns permission error when scope is missing", async () => {
269+
const command = parseCliArgs([
270+
"jobs",
271+
"retry",
272+
"--org",
273+
"acme",
274+
"--pipeline",
275+
"web",
276+
"--build",
277+
"77",
278+
"--job",
279+
"job-1",
280+
]);
281+
282+
const client = createMockClient({
283+
jsonResponses: [
284+
{
285+
status: 200,
286+
headers: new Headers(),
287+
requestId: "req-auth",
288+
data: {
289+
scopes: ["read_builds", "read_build_logs", "read_artifacts"],
290+
},
291+
},
292+
],
293+
});
294+
295+
await assert.rejects(
296+
() => executeCommand({ command, client }),
297+
/token missing required scope\(s\): write_builds/
298+
);
299+
});
300+
191301
test("executeCommand artifacts.download fetches and writes files", async () => {
192302
const outputDir = await mkdtemp(path.join(os.tmpdir(), "bkci-test-"));
193303

0 commit comments

Comments
 (0)