@bakers-build/addon-forge 0.10.0 → 0.10.1-next.2
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/dist/src/backend.d.ts +33 -2
- package/dist/src/backend.js +29 -2
- package/dist/src/preset.js +1 -1
- package/dist/src/tool-definitions.js +21 -2
- package/dist/src/tools.d.ts +7 -2
- package/dist/src/tools.js +58 -9
- package/package.json +5 -5
package/dist/src/backend.d.ts
CHANGED
|
@@ -15,6 +15,13 @@ export interface ForgeCheck {
|
|
|
15
15
|
readonly status: CheckStatus;
|
|
16
16
|
/** Required by the base branch's rules (GitHub) or not allowed to fail (GitLab). */
|
|
17
17
|
readonly required: boolean;
|
|
18
|
+
/** The forge's own verdict (`cancelled`, `skipped`, `timed_out`, ...); null while running. */
|
|
19
|
+
readonly conclusion: string | null;
|
|
20
|
+
/** GitHub Actions run / GitLab pipeline; null for a commit status. */
|
|
21
|
+
readonly run_id: number | null;
|
|
22
|
+
/** The id `forge_job_log` reads; null when the row is no job. */
|
|
23
|
+
readonly job_id: number | null;
|
|
24
|
+
readonly run_started_at: string | null;
|
|
18
25
|
}
|
|
19
26
|
export interface ForgeMerge {
|
|
20
27
|
readonly method: 'squash' | 'merge';
|
|
@@ -35,18 +42,33 @@ export interface ForgeBackend {
|
|
|
35
42
|
title: string;
|
|
36
43
|
body: string;
|
|
37
44
|
}): Promise<ForgePr>;
|
|
45
|
+
/** Also retargets the PR to `base`, which may have moved since it opened. */
|
|
38
46
|
updatePr(pr: ForgePr, input: {
|
|
39
47
|
title: string;
|
|
40
48
|
body: string;
|
|
49
|
+
base: string;
|
|
41
50
|
}): Promise<ForgePr>;
|
|
42
51
|
openPrsWithBase(base: string): Promise<ForgePr[]>;
|
|
43
|
-
/**
|
|
44
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Checks on `ref` (a SHA or branch), `required` as `base`'s rules say. Only
|
|
54
|
+
* the newest run per workflow unless `allRuns`, where the forge has several.
|
|
55
|
+
*/
|
|
56
|
+
checks(ref: string, base: string, opts?: {
|
|
57
|
+
allRuns?: boolean;
|
|
58
|
+
}): Promise<ForgeCheck[]>;
|
|
45
59
|
/** A conflict or a not yet computed mergeability is a reason. */
|
|
46
60
|
mergeBlocker(pr: ForgePr): Promise<string | undefined>;
|
|
47
61
|
/** Merges with every option sent explicitly; deletes the source when asked. */
|
|
48
62
|
merge(pr: ForgePr, options: ForgeMerge): Promise<void>;
|
|
49
63
|
isMerged(pr: ForgePr): Promise<boolean>;
|
|
64
|
+
/** The [tail] of a job's log; refuses a job outside the repository. */
|
|
65
|
+
jobLog(jobId: number, maxBytes: number): Promise<LogTail>;
|
|
66
|
+
}
|
|
67
|
+
export interface LogTail {
|
|
68
|
+
readonly text: string;
|
|
69
|
+
/** The whole log's size. */
|
|
70
|
+
readonly bytes: number;
|
|
71
|
+
readonly truncated: boolean;
|
|
50
72
|
}
|
|
51
73
|
export type ForgeBackendFactory = (settings: unknown, context: {
|
|
52
74
|
readonly home: string;
|
|
@@ -60,7 +82,16 @@ export declare function assertForgeBackend(value: unknown, spec: string): assert
|
|
|
60
82
|
* Every value read is pushed to `seen`, for the backend's `secrets()`.
|
|
61
83
|
*/
|
|
62
84
|
export declare function cliToken(cmd: string, args: string[], seen: string[]): () => Promise<string>;
|
|
85
|
+
/** Text over HTTP for backends: throws status and body text on failure. */
|
|
86
|
+
export declare function requestText(doFetch: typeof fetch, url: string, init: RequestInit & {
|
|
87
|
+
allow404?: boolean;
|
|
88
|
+
}): Promise<string | undefined>;
|
|
63
89
|
/** JSON over HTTP for backends: throws status and body text on failure. */
|
|
64
90
|
export declare function requestJson(doFetch: typeof fetch, url: string, init: RequestInit & {
|
|
65
91
|
allow404?: boolean;
|
|
66
92
|
}): Promise<unknown>;
|
|
93
|
+
/**
|
|
94
|
+
* The last `maxBytes` bytes of `log`, from the first whole line in them; when
|
|
95
|
+
* the last line alone is longer, from the first whole character.
|
|
96
|
+
*/
|
|
97
|
+
export declare function tail(log: string, maxBytes: number): LogTail;
|
package/dist/src/backend.js
CHANGED
|
@@ -14,6 +14,7 @@ const METHODS = [
|
|
|
14
14
|
'mergeBlocker',
|
|
15
15
|
'merge',
|
|
16
16
|
'isMerged',
|
|
17
|
+
'jobLog',
|
|
17
18
|
];
|
|
18
19
|
/** Reject a factory result that is not a `ForgeBackend`. */
|
|
19
20
|
export function assertForgeBackend(value, spec) {
|
|
@@ -44,13 +45,39 @@ export function cliToken(cmd, args, seen) {
|
|
|
44
45
|
return value;
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
|
-
/**
|
|
48
|
-
export async function
|
|
48
|
+
/** Text over HTTP for backends: throws status and body text on failure. */
|
|
49
|
+
export async function requestText(doFetch, url, init) {
|
|
49
50
|
const res = await doFetch(url, init);
|
|
50
51
|
if (res.status === 404 && init.allow404)
|
|
51
52
|
return undefined;
|
|
52
53
|
const text = await res.text();
|
|
53
54
|
if (!res.ok)
|
|
54
55
|
throw new Error(`${init.method ?? 'GET'} ${new URL(url).pathname} answered ${res.status}: ${text.slice(0, 500)}`);
|
|
56
|
+
return text;
|
|
57
|
+
}
|
|
58
|
+
/** JSON over HTTP for backends: throws status and body text on failure. */
|
|
59
|
+
export async function requestJson(doFetch, url, init) {
|
|
60
|
+
const text = await requestText(doFetch, url, init);
|
|
55
61
|
return text ? JSON.parse(text) : undefined;
|
|
56
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* The last `maxBytes` bytes of `log`, from the first whole line in them; when
|
|
65
|
+
* the last line alone is longer, from the first whole character.
|
|
66
|
+
*/
|
|
67
|
+
export function tail(log, maxBytes) {
|
|
68
|
+
const buf = Buffer.from(log);
|
|
69
|
+
if (buf.length <= maxBytes)
|
|
70
|
+
return { text: log, bytes: buf.length, truncated: false };
|
|
71
|
+
let start = buf.length - maxBytes;
|
|
72
|
+
const newline = buf.indexOf(0x0a, start - 1);
|
|
73
|
+
if (newline !== -1 && newline < buf.length - 1)
|
|
74
|
+
start = newline + 1;
|
|
75
|
+
else
|
|
76
|
+
while (start < buf.length && (buf.readUInt8(start) & 0xc0) === 0x80)
|
|
77
|
+
start++;
|
|
78
|
+
return {
|
|
79
|
+
text: buf.subarray(start).toString(),
|
|
80
|
+
bytes: buf.length,
|
|
81
|
+
truncated: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
package/dist/src/preset.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The forge addon:
|
|
1
|
+
// The forge addon: six conductor tools a sandboxed run delivers through,
|
|
2
2
|
// while the token stays with the conductor. Without a forge entry no tool is
|
|
3
3
|
// served and runs deliver as before.
|
|
4
4
|
import { FORGE_TOOL_DEFINITIONS } from './tool-definitions.js';
|
|
@@ -42,17 +42,36 @@ export const FORGE_TOOL_DEFINITIONS = {
|
|
|
42
42
|
},
|
|
43
43
|
},
|
|
44
44
|
forge_checks: {
|
|
45
|
-
description: "Status of each check or pipeline job
|
|
45
|
+
description: "Status of each check or pipeline job on the head of an owned branch's PR/MR, or on any `ref` (branch or SHA); read only, one shot. Each row: status, required, the forge's raw conclusion (cancelled, skipped, timed_out, ...), run_id, job_id (for forge_job_log), run_started_at. overall is success only when every required check (flagged, or named required) is green; else failure, pending or no_required_checks.",
|
|
46
46
|
input: {
|
|
47
47
|
type: 'object',
|
|
48
48
|
properties: {
|
|
49
49
|
branch,
|
|
50
50
|
ref: { type: 'string', description: 'any branch or SHA, read only' },
|
|
51
|
+
all_runs: {
|
|
52
|
+
type: 'boolean',
|
|
53
|
+
description: 'every run on the commit; default: only the newest run per workflow',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
forge_job_log: {
|
|
59
|
+
description: "The end of one job's log, by a job_id from forge_checks, read with the conductor's token; read only. Starts at a whole line; truncated says whether earlier lines were cut.",
|
|
60
|
+
input: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
job_id: { type: 'integer', minimum: 1 },
|
|
64
|
+
max_bytes: {
|
|
65
|
+
type: 'integer',
|
|
66
|
+
minimum: 1,
|
|
67
|
+
description: 'bytes from the end; default 16384, at most 262144',
|
|
68
|
+
},
|
|
51
69
|
},
|
|
70
|
+
required: ['job_id'],
|
|
52
71
|
},
|
|
53
72
|
},
|
|
54
73
|
forge_merge: {
|
|
55
|
-
description: "Merge the open PR/MR of an owned branch into its base, once it is conflict free and
|
|
74
|
+
description: "Merge the open PR/MR of an owned branch into its base, once it is conflict free and overall is success. Human approval is the workflow's merge gate, not this tool's.",
|
|
56
75
|
input: { type: 'object', properties: { branch } },
|
|
57
76
|
},
|
|
58
77
|
};
|
package/dist/src/tools.d.ts
CHANGED
|
@@ -6,8 +6,13 @@ export interface ForgeToolDeps {
|
|
|
6
6
|
/** The forge entry's `with` (merge rules). */
|
|
7
7
|
settings: unknown;
|
|
8
8
|
}
|
|
9
|
-
|
|
10
|
-
export declare
|
|
9
|
+
export declare const LOG_DEFAULT_BYTES = 16384;
|
|
10
|
+
export declare const LOG_MAX_BYTES = 262144;
|
|
11
|
+
export type Overall = 'success' | 'failure' | 'pending' | 'no_required_checks';
|
|
12
|
+
/** Required by the forge, or the aggregate check a workflow names `required`. */
|
|
13
|
+
export declare const isRequired: (c: ForgeCheck) => boolean;
|
|
14
|
+
/** Failure first, then pending, over the required checks; none required is never green. */
|
|
15
|
+
export declare function overall(checks: readonly ForgeCheck[]): Overall;
|
|
11
16
|
export declare function forgeTools(deps: ForgeToolDeps): Record<string, ToolHandler>;
|
|
12
17
|
/** The live tools for the conductor's effective forge entry `with`. */
|
|
13
18
|
export declare function toolsFor(options: unknown): Record<string, ToolHandler>;
|
package/dist/src/tools.js
CHANGED
|
@@ -17,19 +17,45 @@ function runOf(req) {
|
|
|
17
17
|
...(t.targetBranch ? { targetBranch: t.targetBranch } : {}),
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Where `branch` merges: a variant into the ticket branch, the ticket branch into
|
|
22
|
+
* the ticket's current target as the server returns it now. A failed read refuses;
|
|
23
|
+
* the run copy may predate an environment added during the run.
|
|
24
|
+
*/
|
|
25
|
+
async function currentBase(req, branch, run) {
|
|
26
|
+
if (branch !== run.branchName)
|
|
27
|
+
return derivedBase(branch, run);
|
|
28
|
+
const refuse = (why) => new Error(`forge: the ticket's current target_branch could not be read from the server (${why}); nothing was sent`);
|
|
29
|
+
if (!req.currentTicket)
|
|
30
|
+
throw refuse('no server read for this call');
|
|
31
|
+
const t = await req.currentTicket().catch((err) => {
|
|
32
|
+
throw refuse(err instanceof Error ? err.message : String(err));
|
|
33
|
+
});
|
|
34
|
+
const target = t.targetBranch ?? t.baseBranch;
|
|
35
|
+
if (!target)
|
|
36
|
+
throw refuse('no target_branch or base_branch returned');
|
|
37
|
+
return target;
|
|
38
|
+
}
|
|
20
39
|
function ownedBranch(req, run) {
|
|
21
40
|
const branch = req.args.branch ?? run.branchName;
|
|
22
41
|
if (!isOwned(branch, run.branchName))
|
|
23
42
|
throw new Error(ownershipRefusal(branch, run.branchName));
|
|
24
43
|
return branch;
|
|
25
44
|
}
|
|
45
|
+
export const LOG_DEFAULT_BYTES = 16_384;
|
|
46
|
+
export const LOG_MAX_BYTES = 262_144;
|
|
26
47
|
const json = (value) => ({
|
|
27
48
|
text: JSON.stringify(value, null, 2),
|
|
28
49
|
});
|
|
29
|
-
/**
|
|
50
|
+
/** Required by the forge, or the aggregate check a workflow names `required`. */
|
|
51
|
+
export const isRequired = (c) => c.required || c.name === 'required';
|
|
52
|
+
const isRed = (c) => c.status === 'failure' || c.conclusion === 'skipped';
|
|
53
|
+
/** Failure first, then pending, over the required checks; none required is never green. */
|
|
30
54
|
export function overall(checks) {
|
|
31
|
-
const required = checks.filter(
|
|
32
|
-
if (required.
|
|
55
|
+
const required = checks.filter(isRequired);
|
|
56
|
+
if (required.length === 0)
|
|
57
|
+
return 'no_required_checks';
|
|
58
|
+
if (required.some(isRed))
|
|
33
59
|
return 'failure';
|
|
34
60
|
if (required.some((c) => c.status !== 'success'))
|
|
35
61
|
return 'pending';
|
|
@@ -91,13 +117,14 @@ export function forgeTools(deps) {
|
|
|
91
117
|
const run = runOf(req);
|
|
92
118
|
const branch = ownedBranch(req, run);
|
|
93
119
|
const body = req.args.body ?? '';
|
|
120
|
+
const base = await currentBase(req, branch, run);
|
|
94
121
|
const b = await backend();
|
|
95
122
|
const open = await b.findOpenPr(branch);
|
|
96
123
|
const pr = open
|
|
97
|
-
? await b.updatePr(open, { title, body })
|
|
124
|
+
? await b.updatePr(open, { title, body, base })
|
|
98
125
|
: await b.createPr({
|
|
99
126
|
head: branch,
|
|
100
|
-
base
|
|
127
|
+
base,
|
|
101
128
|
title,
|
|
102
129
|
body,
|
|
103
130
|
});
|
|
@@ -122,13 +149,15 @@ export function forgeTools(deps) {
|
|
|
122
149
|
ref = pr?.headSha ?? branch;
|
|
123
150
|
base = pr?.base ?? derivedBase(branch, run);
|
|
124
151
|
}
|
|
125
|
-
const checks = await b.checks(ref, base
|
|
152
|
+
const checks = await b.checks(ref, base, {
|
|
153
|
+
allRuns: req.args.all_runs === true,
|
|
154
|
+
});
|
|
126
155
|
return json({ ref, base, overall: overall(checks), checks });
|
|
127
156
|
},
|
|
128
157
|
forge_merge: async (req) => {
|
|
129
158
|
const run = runOf(req);
|
|
130
159
|
const branch = ownedBranch(req, run);
|
|
131
|
-
const base =
|
|
160
|
+
const base = await currentBase(req, branch, run);
|
|
132
161
|
const b = await backend();
|
|
133
162
|
const pr = await b.findOpenPr(branch);
|
|
134
163
|
if (!pr)
|
|
@@ -141,9 +170,11 @@ export function forgeTools(deps) {
|
|
|
141
170
|
if (blocker)
|
|
142
171
|
throw new Error(`PR/MR ${pr.url} cannot merge: ${blocker}`);
|
|
143
172
|
const checks = await b.checks(pr.headSha, pr.base);
|
|
144
|
-
|
|
173
|
+
if (overall(checks) === 'no_required_checks')
|
|
174
|
+
throw new Error(`PR/MR ${pr.url} cannot merge: no required checks reported`);
|
|
175
|
+
const notGreen = checks.filter((c) => isRequired(c) && (isRed(c) || c.status !== 'success'));
|
|
145
176
|
if (notGreen.length > 0)
|
|
146
|
-
throw new Error(`PR/MR ${pr.url} has required checks that are not green: ${notGreen.map((c) => `${c.name} (${c.status})`).join(', ')}`);
|
|
177
|
+
throw new Error(`PR/MR ${pr.url} has required checks that are not green: ${notGreen.map((c) => `${c.name} (${c.conclusion ?? c.status})`).join(', ')}`);
|
|
147
178
|
const rule = mergeRule(deps.settings, base);
|
|
148
179
|
const dependents = (await b.openPrsWithBase(branch)).filter((p) => p.number !== pr.number);
|
|
149
180
|
const deleteSource = rule.delete_source && dependents.length === 0;
|
|
@@ -166,6 +197,24 @@ export function forgeTools(deps) {
|
|
|
166
197
|
: {}),
|
|
167
198
|
});
|
|
168
199
|
},
|
|
200
|
+
forge_job_log: async (req) => {
|
|
201
|
+
runOf(req);
|
|
202
|
+
const jobId = req.args.job_id;
|
|
203
|
+
if (typeof jobId !== 'number' || !Number.isInteger(jobId) || jobId <= 0)
|
|
204
|
+
throw new Error('job_id must be a job_id from forge_checks');
|
|
205
|
+
const asked = req.args.max_bytes;
|
|
206
|
+
const maxBytes = typeof asked === 'number' && asked >= 1
|
|
207
|
+
? Math.min(Math.floor(asked), LOG_MAX_BYTES)
|
|
208
|
+
: LOG_DEFAULT_BYTES;
|
|
209
|
+
const log = await (await backend()).jobLog(jobId, maxBytes);
|
|
210
|
+
return json({
|
|
211
|
+
job_id: jobId,
|
|
212
|
+
bytes: log.bytes,
|
|
213
|
+
truncated: log.truncated,
|
|
214
|
+
max_bytes: maxBytes,
|
|
215
|
+
log: log.text,
|
|
216
|
+
});
|
|
217
|
+
},
|
|
169
218
|
};
|
|
170
219
|
return Object.fromEntries(Object.entries(tools).map(([n, h]) => [n, guarded(h)]));
|
|
171
220
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakers-build/addon-forge",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.1-next.2",
|
|
4
4
|
"description": "bakers.build forge tools: a sandboxed run fetches, pushes its own branches, opens its PR/MR, reads checks and merges through the conductor, which holds the token. Backends implement the ForgeBackend contract (./backend).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
"directory": "bakers-cli/addons/forge"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"@bakers-build/conductor": "^0.10.
|
|
26
|
-
"@bakers-build/core": "^0.10.
|
|
25
|
+
"@bakers-build/conductor": "^0.10.1-next.2",
|
|
26
|
+
"@bakers-build/core": "^0.10.1-next.2"
|
|
27
27
|
},
|
|
28
28
|
"bakersBuild": {
|
|
29
|
-
"commit": "
|
|
30
|
-
"channel": "
|
|
29
|
+
"commit": "1bc392c2e9aaebacbb1904ad2b55563da8784233",
|
|
30
|
+
"channel": "next"
|
|
31
31
|
}
|
|
32
32
|
}
|