Skip to content

Commit 05c9b9c

Browse files
committed
Fix pagination parsing and strip ansi from normalized logs
1 parent 9daf557 commit 05c9b9c

7 files changed

Lines changed: 273 additions & 16 deletions

File tree

API.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ List commands must fill `pagination`.
9494
}
9595
```
9696

97-
Buildkite pagination details come from response headers. If a value is unavailable, set it to `null`.
97+
Buildkite pagination details come from response headers, primarily the `Link` header.
98+
If a value is unavailable, set it to `null`.
9899

99100
## Raw mode
100101

@@ -242,6 +243,9 @@ Get one build and include a flattened job summary.
242243

243244
Fetch one job log.
244245

246+
In normalized mode, `data.content` has ANSI and Buildkite control sequences removed.
247+
Use `--raw` to keep exact Buildkite payloads.
248+
245249
### Request
246250

247251
```json

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,5 @@ bkci annotations list --org ORG --pipeline PIPELINE --build BUILD_NUMBER
5151
## Notes
5252

5353
- Use `--raw` on any command to return raw Buildkite payloads inside the envelope.
54-
- Pagination metadata is included for list endpoints when available.
54+
- `jobs log get` strips ANSI/control sequences in normalized mode for cleaner LLM output.
55+
- Pagination metadata is parsed from Buildkite `Link` headers for list endpoints.

src/cli/execute-command.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,16 @@ function mapAnnotation(annotation: unknown): Record<string, unknown> {
136136
};
137137
}
138138

139-
function getPaginationOrNull(commandName: ParsedCommand["name"], headers: Headers): Pagination | null {
140-
if (commandName === "builds.list") {
141-
return parsePagination(headers);
139+
function getPaginationOrNull(options: {
140+
readonly command: ParsedCommand;
141+
readonly headers: Headers;
142+
}): Pagination | null {
143+
if (options.command.name === "builds.list") {
144+
return parsePagination({
145+
headers: options.headers,
146+
requestedPage: options.command.args.page,
147+
requestedPerPage: options.command.args.perPage,
148+
});
142149
}
143150
return null;
144151
}
@@ -254,7 +261,7 @@ export async function executeCommand(options: {
254261
return {
255262
request,
256263
summary: {},
257-
pagination: getPaginationOrNull(options.command.name, response.headers),
264+
pagination: getPaginationOrNull({ command: options.command, headers: response.headers }),
258265
data: response.data,
259266
};
260267
}
@@ -266,7 +273,7 @@ export async function executeCommand(options: {
266273
count: builds.length,
267274
states: getSummaryStates(builds),
268275
},
269-
pagination: getPaginationOrNull(options.command.name, response.headers),
276+
pagination: getPaginationOrNull({ command: options.command, headers: response.headers }),
270277
data: builds,
271278
};
272279
}
@@ -326,6 +333,7 @@ export async function executeCommand(options: {
326333
rawContent,
327334
maxBytes: options.command.args.maxBytes,
328335
tailLineCount: options.command.args.tailLines,
336+
stripAnsi: true,
329337
});
330338

331339
return {

src/core/logs.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ test("transformLogContent applies tail lines first", () => {
88
rawContent: "line-1\nline-2\nline-3\nline-4",
99
maxBytes: null,
1010
tailLineCount: 2,
11+
stripAnsi: false,
1112
});
1213

1314
assert.equal(result.content, "line-3\nline-4");
@@ -20,8 +21,25 @@ test("transformLogContent truncates by bytes when maxBytes is set", () => {
2021
rawContent: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2122
maxBytes: 8,
2223
tailLineCount: null,
24+
stripAnsi: false,
2325
});
2426

2527
assert.equal(Buffer.byteLength(result.content, "utf8") <= 8, true);
2628
assert.equal(result.truncated, true);
2729
});
30+
31+
test("transformLogContent strips ansi and buildkite control sequences", () => {
32+
const raw =
33+
"\u001b_bk;t=1770510296903\u0007\u001b[38;5;48mINFO\u001b[0m hello\r\nnext-line\u001b[0m";
34+
35+
const result = transformLogContent({
36+
rawContent: raw,
37+
maxBytes: null,
38+
tailLineCount: null,
39+
stripAnsi: true,
40+
});
41+
42+
assert.equal(result.content.includes("\u001b"), false);
43+
assert.equal(result.content.includes("\r"), false);
44+
assert.equal(result.content.includes("INFO hello"), true);
45+
});

src/core/logs.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,27 @@ function truncateToMaxBytes(value: string, maxBytes: number): { readonly content
3232
return { content, truncated: true };
3333
}
3434

35+
export function stripAnsiAndControlSequences(value: string): string {
36+
return value
37+
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
38+
.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "")
39+
.replace(/\u001b_[^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "")
40+
.replace(/\r/g, "");
41+
}
42+
3543
export function transformLogContent(options: {
3644
readonly rawContent: string;
3745
readonly maxBytes: number | null;
3846
readonly tailLineCount: number | null;
47+
readonly stripAnsi: boolean;
3948
}): LogTransformResult {
49+
const sanitized = options.stripAnsi
50+
? stripAnsiAndControlSequences(options.rawContent)
51+
: options.rawContent;
52+
4053
const tailed = options.tailLineCount === null
41-
? options.rawContent
42-
: tailLines(options.rawContent, options.tailLineCount);
54+
? sanitized
55+
: tailLines(sanitized, options.tailLineCount);
4356

4457
const truncated = options.maxBytes === null
4558
? { content: tailed, truncated: false }

src/core/pagination.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { parsePagination } from "./pagination.js";
5+
6+
test("parsePagination derives values from Link header on first page", () => {
7+
const headers = new Headers({
8+
link: '<https://api.buildkite.com/v2/organizations/acme/pipelines/web/builds?page=2&per_page=3>; rel="next", <https://api.buildkite.com/v2/organizations/acme/pipelines/web/builds?page=42&per_page=3>; rel="last"',
9+
});
10+
11+
const pagination = parsePagination({
12+
headers,
13+
requestedPage: null,
14+
requestedPerPage: 3,
15+
});
16+
17+
assert.deepEqual(pagination, {
18+
page: 1,
19+
perPage: 3,
20+
nextPage: 2,
21+
prevPage: null,
22+
hasMore: true,
23+
});
24+
});
25+
26+
test("parsePagination uses prev relation to infer current page", () => {
27+
const headers = new Headers({
28+
link: '<https://api.buildkite.com/v2/organizations/acme/pipelines/web/builds?page=2&per_page=10>; rel="prev", <https://api.buildkite.com/v2/organizations/acme/pipelines/web/builds?page=4&per_page=10>; rel="next"',
29+
});
30+
31+
const pagination = parsePagination({
32+
headers,
33+
requestedPage: null,
34+
requestedPerPage: null,
35+
});
36+
37+
assert.deepEqual(pagination, {
38+
page: 3,
39+
perPage: 10,
40+
nextPage: 4,
41+
prevPage: 2,
42+
hasMore: true,
43+
});
44+
});
45+
46+
test("parsePagination honors explicit requested page when no headers exist", () => {
47+
const pagination = parsePagination({
48+
headers: new Headers(),
49+
requestedPage: 5,
50+
requestedPerPage: 25,
51+
});
52+
53+
assert.deepEqual(pagination, {
54+
page: 5,
55+
perPage: 25,
56+
nextPage: null,
57+
prevPage: null,
58+
hasMore: false,
59+
});
60+
});

src/core/pagination.ts

Lines changed: 160 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,174 @@
11
import type { Pagination } from "./types.js";
22

3-
function parseNumberHeader(value: string | null): number | null {
4-
if (!value) {
3+
type LinkRelations = {
4+
readonly next: URL | null;
5+
readonly prev: URL | null;
6+
readonly first: URL | null;
7+
readonly last: URL | null;
8+
};
9+
10+
function parseNumber(value: string | null): number | null {
11+
if (value === null) {
512
return null;
613
}
14+
715
const parsed = Number.parseInt(value, 10);
816
if (!Number.isFinite(parsed)) {
917
return null;
1018
}
19+
1120
return parsed;
1221
}
1322

14-
export function parsePagination(headers: Headers): Pagination {
15-
const page = parseNumberHeader(headers.get("x-page"));
16-
const perPage = parseNumberHeader(headers.get("x-per-page"));
17-
const nextPage = parseNumberHeader(headers.get("x-next-page"));
18-
const prevPage = parseNumberHeader(headers.get("x-prev-page"));
23+
function parseLinkRelations(linkHeader: string | null): LinkRelations {
24+
if (linkHeader === null || linkHeader.trim().length === 0) {
25+
return {
26+
next: null,
27+
prev: null,
28+
first: null,
29+
last: null,
30+
};
31+
}
32+
33+
const result: {
34+
next: URL | null;
35+
prev: URL | null;
36+
first: URL | null;
37+
last: URL | null;
38+
} = {
39+
next: null,
40+
prev: null,
41+
first: null,
42+
last: null,
43+
};
44+
45+
const parts = linkHeader.split(",");
46+
for (const part of parts) {
47+
const match = part.match(/<([^>]+)>\s*;\s*rel="([a-z]+)"/i);
48+
if (!match) {
49+
continue;
50+
}
51+
52+
const [, href, relation] = match;
53+
if (!href || !relation) {
54+
continue;
55+
}
56+
57+
try {
58+
const parsedUrl = new URL(href);
59+
if (relation === "next") {
60+
result.next = parsedUrl;
61+
} else if (relation === "prev") {
62+
result.prev = parsedUrl;
63+
} else if (relation === "first") {
64+
result.first = parsedUrl;
65+
} else if (relation === "last") {
66+
result.last = parsedUrl;
67+
}
68+
} catch {
69+
// Ignore malformed URL in link relation.
70+
}
71+
}
72+
73+
return result;
74+
}
75+
76+
function readPageFromUrl(url: URL | null): number | null {
77+
if (url === null) {
78+
return null;
79+
}
80+
return parseNumber(url.searchParams.get("page"));
81+
}
82+
83+
function readPerPageFromUrl(url: URL | null): number | null {
84+
if (url === null) {
85+
return null;
86+
}
87+
return parseNumber(url.searchParams.get("per_page"));
88+
}
89+
90+
function deriveCurrentPage(options: {
91+
readonly fromHeader: number | null;
92+
readonly requestedPage: number | null;
93+
readonly nextPage: number | null;
94+
readonly prevPage: number | null;
95+
readonly firstPage: number | null;
96+
}): number | null {
97+
if (options.fromHeader !== null) {
98+
return options.fromHeader;
99+
}
100+
if (options.requestedPage !== null) {
101+
return options.requestedPage;
102+
}
103+
if (options.prevPage !== null) {
104+
return options.prevPage + 1;
105+
}
106+
if (options.nextPage !== null && options.nextPage > 1) {
107+
return options.nextPage - 1;
108+
}
109+
if (options.firstPage !== null) {
110+
return options.firstPage;
111+
}
112+
if (options.nextPage !== null) {
113+
return 1;
114+
}
115+
return null;
116+
}
117+
118+
function derivePerPage(options: {
119+
readonly fromHeader: number | null;
120+
readonly requestedPerPage: number | null;
121+
readonly links: LinkRelations;
122+
}): number | null {
123+
if (options.fromHeader !== null) {
124+
return options.fromHeader;
125+
}
126+
if (options.requestedPerPage !== null) {
127+
return options.requestedPerPage;
128+
}
129+
130+
const fromNext = readPerPageFromUrl(options.links.next);
131+
if (fromNext !== null) {
132+
return fromNext;
133+
}
134+
135+
const fromPrev = readPerPageFromUrl(options.links.prev);
136+
if (fromPrev !== null) {
137+
return fromPrev;
138+
}
139+
140+
const fromFirst = readPerPageFromUrl(options.links.first);
141+
if (fromFirst !== null) {
142+
return fromFirst;
143+
}
144+
145+
return readPerPageFromUrl(options.links.last);
146+
}
147+
148+
export function parsePagination(options: {
149+
readonly headers: Headers;
150+
readonly requestedPage: number | null;
151+
readonly requestedPerPage: number | null;
152+
}): Pagination {
153+
const links = parseLinkRelations(options.headers.get("link"));
154+
155+
const nextPage = parseNumber(options.headers.get("x-next-page")) ?? readPageFromUrl(links.next);
156+
const prevPage = parseNumber(options.headers.get("x-prev-page")) ?? readPageFromUrl(links.prev);
157+
const firstPage = readPageFromUrl(links.first);
158+
159+
const page = deriveCurrentPage({
160+
fromHeader: parseNumber(options.headers.get("x-page")),
161+
requestedPage: options.requestedPage,
162+
nextPage,
163+
prevPage,
164+
firstPage,
165+
});
166+
167+
const perPage = derivePerPage({
168+
fromHeader: parseNumber(options.headers.get("x-per-page")),
169+
requestedPerPage: options.requestedPerPage,
170+
links,
171+
});
19172

20173
return {
21174
page,

0 commit comments

Comments
 (0)