@bakers-build/addon-forge 0.0.0-stage → 0.8.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keytec GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @bakers-build/addon-forge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 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).
4
+
5
+ Part of the bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://github.com/bakers-build/bakers-build (bakers-cli/).
@@ -0,0 +1,66 @@
1
+ import type { GitAuth } from './git.js';
2
+ export type { GitAuth };
3
+ export interface ForgePr {
4
+ /** GitHub PR number, GitLab MR iid. */
5
+ readonly number: number;
6
+ readonly url: string;
7
+ readonly head: string;
8
+ readonly base: string;
9
+ readonly title: string;
10
+ readonly headSha: string;
11
+ }
12
+ export type CheckStatus = 'success' | 'pending' | 'failure' | 'missing';
13
+ export interface ForgeCheck {
14
+ readonly name: string;
15
+ readonly status: CheckStatus;
16
+ /** Required by the base branch's rules (GitHub) or not allowed to fail (GitLab). */
17
+ readonly required: boolean;
18
+ }
19
+ export interface ForgeMerge {
20
+ readonly method: 'squash' | 'merge';
21
+ readonly deleteSource: boolean;
22
+ readonly subject: string;
23
+ readonly body: string;
24
+ }
25
+ export interface ForgeBackend {
26
+ readonly id: string;
27
+ /** Every credential value; the tools redact them from all output. */
28
+ secrets(): readonly string[];
29
+ /** The configured repository URL and the header that authenticates git. */
30
+ gitAuth(): Promise<GitAuth>;
31
+ findOpenPr(head: string): Promise<ForgePr | undefined>;
32
+ createPr(input: {
33
+ head: string;
34
+ base: string;
35
+ title: string;
36
+ body: string;
37
+ }): Promise<ForgePr>;
38
+ updatePr(pr: ForgePr, input: {
39
+ title: string;
40
+ body: string;
41
+ }): Promise<ForgePr>;
42
+ openPrsWithBase(base: string): Promise<ForgePr[]>;
43
+ /** Checks on `ref` (a SHA or branch), `required` as `base`'s rules say. */
44
+ checks(ref: string, base: string): Promise<ForgeCheck[]>;
45
+ /** A conflict or a not yet computed mergeability is a reason. */
46
+ mergeBlocker(pr: ForgePr): Promise<string | undefined>;
47
+ /** Merges with every option sent explicitly; deletes the source when asked. */
48
+ merge(pr: ForgePr, options: ForgeMerge): Promise<void>;
49
+ isMerged(pr: ForgePr): Promise<boolean>;
50
+ }
51
+ export type ForgeBackendFactory = (settings: unknown, context: {
52
+ readonly home: string;
53
+ readonly fetch?: typeof fetch;
54
+ }) => Promise<ForgeBackend>;
55
+ /** Reject a factory result that is not a `ForgeBackend`. */
56
+ export declare function assertForgeBackend(value: unknown, spec: string): asserts value is ForgeBackend;
57
+ /**
58
+ * A token from the conductor's own CLI login (`gh`, `glab`), run outside any
59
+ * sandbox and read again after five minutes so a new login is picked up.
60
+ * Every value read is pushed to `seen`, for the backend's `secrets()`.
61
+ */
62
+ export declare function cliToken(cmd: string, args: string[], seen: string[]): () => Promise<string>;
63
+ /** JSON over HTTP for backends: throws status and body text on failure. */
64
+ export declare function requestJson(doFetch: typeof fetch, url: string, init: RequestInit & {
65
+ allow404?: boolean;
66
+ }): Promise<unknown>;
@@ -0,0 +1,56 @@
1
+ // The forge backend contract. A backend package (`@bakers-build/addon-forge-
2
+ // github`, `@bakers-build/addon-forge-gitlab`) default-exports a
3
+ // `ForgeBackendFactory`; the forge tools know only this shape, never a
4
+ // service's API. Ownership, titles and merge gates stay with the tools.
5
+ import { execFile } from 'node:child_process';
6
+ const METHODS = [
7
+ 'secrets',
8
+ 'gitAuth',
9
+ 'findOpenPr',
10
+ 'createPr',
11
+ 'updatePr',
12
+ 'openPrsWithBase',
13
+ 'checks',
14
+ 'mergeBlocker',
15
+ 'merge',
16
+ 'isMerged',
17
+ ];
18
+ /** Reject a factory result that is not a `ForgeBackend`. */
19
+ export function assertForgeBackend(value, spec) {
20
+ const obj = value;
21
+ if (typeof obj !== 'object' || obj === null)
22
+ throw new Error(`forge backend '${spec}' did not return an object`);
23
+ if (typeof obj.id !== 'string' || obj.id === '')
24
+ throw new Error(`forge backend '${spec}' is missing string "id"`);
25
+ for (const m of METHODS)
26
+ if (typeof obj[m] !== 'function')
27
+ throw new Error(`forge backend '${spec}' is missing function "${m}"`);
28
+ }
29
+ /**
30
+ * A token from the conductor's own CLI login (`gh`, `glab`), run outside any
31
+ * sandbox and read again after five minutes so a new login is picked up.
32
+ * Every value read is pushed to `seen`, for the backend's `secrets()`.
33
+ */
34
+ export function cliToken(cmd, args, seen) {
35
+ let cached;
36
+ return async () => {
37
+ if (cached && Date.now() < cached.until)
38
+ return cached.value;
39
+ const value = await new Promise((resolve) => execFile(cmd, args, { timeout: 10_000 }, (err, out) => resolve(err ? '' : String(out).trim())));
40
+ if (!value)
41
+ throw new Error(`${cmd} gave no token: run '${cmd} auth login' on the conductor's machine`);
42
+ seen.push(value);
43
+ cached = { value, until: Date.now() + 300_000 };
44
+ return value;
45
+ };
46
+ }
47
+ /** JSON over HTTP for backends: throws status and body text on failure. */
48
+ export async function requestJson(doFetch, url, init) {
49
+ const res = await doFetch(url, init);
50
+ if (res.status === 404 && init.allow404)
51
+ return undefined;
52
+ const text = await res.text();
53
+ if (!res.ok)
54
+ throw new Error(`${init.method ?? 'GET'} ${new URL(url).pathname} answered ${res.status}: ${text.slice(0, 500)}`);
55
+ return text ? JSON.parse(text) : undefined;
56
+ }
@@ -0,0 +1,13 @@
1
+ import { type ForgeBackend } from './backend.js';
2
+ export declare const FORGE_SPEC = "@bakers-build/addon-forge";
3
+ export declare const FORGE_NEEDS_BACKEND = "addons[@bakers-build/addon-forge].with.backend.use must name a forge backend ('@bakers-build/addon-forge-github' or '@bakers-build/addon-forge-gitlab') in ~/.bakers/machine.config.js or .bakers/project.config.js; see docs/addons/forge";
4
+ export interface MergeRule {
5
+ readonly method: 'squash' | 'merge';
6
+ readonly delete_source: boolean;
7
+ }
8
+ /** The machine wins key by key (a machine `backend` replaces the project's). */
9
+ export declare function mergeWith(project: unknown, machine: unknown): unknown;
10
+ /** The rule for merging into `base`: `merge[base]`, else `merge['*']`, else squash and delete. */
11
+ export declare function mergeRule(settings: unknown, base: string): MergeRule;
12
+ /** Resolve, load and construct the configured backend. */
13
+ export declare function loadForgeBackend(settings: unknown, home: string): Promise<ForgeBackend>;
@@ -0,0 +1,67 @@
1
+ // The forge entry's `with`: `merge` rules per base branch and exactly one
2
+ // `backend` (`use` names the package, `with` is that package's settings);
3
+ // options.schema.json describes them.
4
+ import { realpathSync } from 'node:fs';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { resolveModuleEslintStyle } from '@bakers-build/core';
7
+ import { assertForgeBackend, } from './backend.js';
8
+ export const FORGE_SPEC = '@bakers-build/addon-forge';
9
+ export const FORGE_NEEDS_BACKEND = `addons[${FORGE_SPEC}].with.backend.use must name a forge backend ('@bakers-build/addon-forge-github' or '@bakers-build/addon-forge-gitlab') in ~/.bakers/machine.config.js or .bakers/project.config.js; see docs/addons/forge`;
10
+ const DEFAULT_RULE = { method: 'squash', delete_source: true };
11
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
12
+ /** The machine wins key by key (a machine `backend` replaces the project's). */
13
+ export function mergeWith(project, machine) {
14
+ if (!isObject(machine))
15
+ return machine === undefined ? project : machine;
16
+ if (!isObject(project))
17
+ return machine;
18
+ return { ...project, ...machine };
19
+ }
20
+ /** The rule for merging into `base`: `merge[base]`, else `merge['*']`, else squash and delete. */
21
+ export function mergeRule(settings, base) {
22
+ const merge = isObject(settings) && isObject(settings.merge) ? settings.merge : {};
23
+ return (merge[base] ?? merge['*'] ?? DEFAULT_RULE);
24
+ }
25
+ /** Resolution bases rooted at the running `bakers` host install (see addon-ports' config). */
26
+ function hostInstallBases() {
27
+ const entry = process.argv[1];
28
+ if (!entry)
29
+ return [import.meta.url];
30
+ let real;
31
+ try {
32
+ real = realpathSync(entry);
33
+ }
34
+ catch {
35
+ real = entry;
36
+ }
37
+ const entryUrl = pathToFileURL(real);
38
+ return [entryUrl.href, new URL('../host/dist/src/host.js', entryUrl).href];
39
+ }
40
+ /** Resolve, load and construct the configured backend. */
41
+ export async function loadForgeBackend(settings, home) {
42
+ const backend = isObject(settings) ? settings.backend : undefined;
43
+ if (!isObject(backend) ||
44
+ typeof backend.use !== 'string' ||
45
+ backend.use === '')
46
+ throw new Error(FORGE_NEEDS_BACKEND);
47
+ const spec = backend.use;
48
+ const resolved = resolveModuleEslintStyle(spec, [
49
+ pathToFileURL(`${home}/`).href,
50
+ ...hostInstallBases(),
51
+ pathToFileURL(`${process.cwd()}/`).href,
52
+ ]);
53
+ if (resolved === undefined)
54
+ throw new Error(`forge cannot resolve backend package '${spec}'`);
55
+ const factory = (await import(pathToFileURL(resolved).href)).default;
56
+ if (typeof factory !== 'function')
57
+ throw new Error(`forge backend '${spec}' has no default export factory`);
58
+ let made;
59
+ try {
60
+ made = await factory(backend.with, { home });
61
+ }
62
+ catch (err) {
63
+ throw new Error(`forge backend '${spec}' rejected its settings: ${err instanceof Error ? err.message : String(err)}`);
64
+ }
65
+ assertForgeBackend(made, spec);
66
+ return made;
67
+ }
@@ -0,0 +1,28 @@
1
+ export interface GitAuth {
2
+ /** The configured repository URL, never the clone's `origin`. */
3
+ readonly url: string;
4
+ /** An HTTP header line (`Authorization: …`) git sends to the remote. */
5
+ readonly header: string;
6
+ }
7
+ export interface PushLease {
8
+ readonly forceWithLease?: boolean;
9
+ /** The remote SHA the lease expects; default: refs/remotes/origin/<branch>. */
10
+ readonly expectedSha?: string;
11
+ }
12
+ /** The SHA of a local branch of the clone, if it exists. */
13
+ export declare function localSha(worktree: string, branch: string): Promise<string | undefined>;
14
+ /**
15
+ * Fetch `branches` into the clone's `refs/remotes/origin/<name>`, no tags.
16
+ * Branches the remote lacks are skipped. Changes nothing remote.
17
+ */
18
+ export declare function fetchBranches(worktree: string, auth: GitAuth, branches: readonly string[]): Promise<{
19
+ name: string;
20
+ sha: string;
21
+ }[]>;
22
+ /**
23
+ * Push the clone's `refs/heads/<branch>` to the remote's branch of the same
24
+ * name. Without a lease only a fast-forward lands; with one, the remote must
25
+ * still be at the expected SHA (absent remote-tracking ref: no branch).
26
+ * Returns the pushed SHA and records it as refs/remotes/origin/<branch>.
27
+ */
28
+ export declare function pushBranch(worktree: string, auth: GitAuth, branch: string, lease: PushLease): Promise<string>;
@@ -0,0 +1,177 @@
1
+ // Network git that trusts nothing in the run's clone. Every fetch and push
2
+ // runs in a throwaway bare repository whose object directory is the clone's
3
+ // own store, with no global, system or repository config, no hooks, and only
4
+ // the remote's protocol allowed. The token reaches git as an environment
5
+ // header, never argv, URL or file. Against the clone itself only `rev-parse`,
6
+ // `for-each-ref` and `update-ref` run, with hooks and fsmonitor disabled.
7
+ import { execFile } from 'node:child_process';
8
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ const HARDEN = ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false'];
12
+ function baseEnv(home) {
13
+ return {
14
+ PATH: process.env.PATH,
15
+ HOME: home,
16
+ LANG: 'C',
17
+ GIT_CONFIG_NOSYSTEM: '1',
18
+ GIT_CONFIG_GLOBAL: '/dev/null',
19
+ GIT_TERMINAL_PROMPT: '0',
20
+ };
21
+ }
22
+ function run(args, env, input) {
23
+ return new Promise((resolve, reject) => {
24
+ const child = execFile('git', args, { env, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
25
+ if (err)
26
+ reject(new Error(`git failed: ${stderr.trim() || err.message}`));
27
+ else
28
+ resolve(stdout.trim());
29
+ });
30
+ if (input !== undefined)
31
+ child.stdin?.end(input);
32
+ });
33
+ }
34
+ /** The clone's common git dir (shared by all its worktrees). */
35
+ async function commonDir(worktree, env) {
36
+ return run([
37
+ '-C',
38
+ worktree,
39
+ ...HARDEN,
40
+ 'rev-parse',
41
+ '--path-format=absolute',
42
+ '--git-common-dir',
43
+ ], env);
44
+ }
45
+ const inClone = (common, env, ...args) => run([`--git-dir=${common}`, ...HARDEN, ...args], env);
46
+ async function readRef(common, env, ref) {
47
+ try {
48
+ return await inClone(common, env, 'rev-parse', '--verify', '-q', `${ref}^{commit}`);
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ /** Run `body` with a scratch repository on the clone's object store. */
55
+ async function withScratch(worktree, auth, body) {
56
+ const home = await mkdtemp(join(tmpdir(), 'bakers-forge-'));
57
+ try {
58
+ const env = baseEnv(home);
59
+ const common = await commonDir(worktree, env);
60
+ // The conductor's git would read any object store the run lists here.
61
+ const alternates = await readFile(join(common, 'objects', 'info', 'alternates'), 'utf8').catch(() => '');
62
+ if (alternates.trim())
63
+ throw new Error('the clone lists object alternates (objects/info/alternates); forge tools refuse a borrowed object store');
64
+ const scratch = join(home, 'scratch.git');
65
+ await run(['init', '--bare', '-q', scratch], env);
66
+ const scheme = new URL(auth.url).protocol.slice(0, -1);
67
+ const config = [
68
+ ['core.hooksPath', '/dev/null'],
69
+ ['core.fsmonitor', 'false'],
70
+ ['protocol.allow', 'never'],
71
+ [`protocol.${scheme}.allow`, 'always'],
72
+ ['gc.auto', '0'],
73
+ ['maintenance.auto', 'false'],
74
+ ['http.extraHeader', auth.header],
75
+ ];
76
+ const remoteEnv = {
77
+ ...env,
78
+ GIT_DIR: scratch,
79
+ GIT_OBJECT_DIRECTORY: join(common, 'objects'),
80
+ GIT_CONFIG_COUNT: String(config.length),
81
+ };
82
+ config.forEach(([key, value], i) => {
83
+ remoteEnv[`GIT_CONFIG_KEY_${i}`] = key;
84
+ remoteEnv[`GIT_CONFIG_VALUE_${i}`] = value;
85
+ });
86
+ return await body({
87
+ common,
88
+ env,
89
+ remote: (args, input) => run(args, remoteEnv, input),
90
+ });
91
+ }
92
+ finally {
93
+ await rm(home, { recursive: true, force: true });
94
+ }
95
+ }
96
+ /** The SHA of a local branch of the clone, if it exists. */
97
+ export async function localSha(worktree, branch) {
98
+ const home = await mkdtemp(join(tmpdir(), 'bakers-forge-'));
99
+ try {
100
+ const env = baseEnv(home);
101
+ return await readRef(await commonDir(worktree, env), env, `refs/heads/${branch}`);
102
+ }
103
+ finally {
104
+ await rm(home, { recursive: true, force: true });
105
+ }
106
+ }
107
+ /** Remote heads of `branches` as `name → sha`; absent names are left out. */
108
+ function parseLsRemote(out) {
109
+ const heads = new Map();
110
+ for (const line of out.split('\n')) {
111
+ const [sha, ref] = line.split('\t');
112
+ if (sha && ref?.startsWith('refs/heads/'))
113
+ heads.set(ref.slice('refs/heads/'.length), sha);
114
+ }
115
+ return heads;
116
+ }
117
+ /**
118
+ * Fetch `branches` into the clone's `refs/remotes/origin/<name>`, no tags.
119
+ * Branches the remote lacks are skipped. Changes nothing remote.
120
+ */
121
+ export async function fetchBranches(worktree, auth, branches) {
122
+ return withScratch(worktree, auth, async ({ common, env, remote }) => {
123
+ const heads = parseLsRemote(await remote([
124
+ 'ls-remote',
125
+ '--heads',
126
+ auth.url,
127
+ ...branches.map((b) => `refs/heads/${b}`),
128
+ ]));
129
+ const wanted = branches.filter((b, i) => heads.has(b) && branches.indexOf(b) === i);
130
+ if (wanted.length === 0)
131
+ return [];
132
+ // The clone's remote-tracking refs seed negotiation, so only new objects travel.
133
+ const known = await inClone(common, env, 'for-each-ref', '--format=create %(refname) %(objectname)', 'refs/remotes/origin/');
134
+ // A ref to an object the store lacks only costs negotiation; ignore it.
135
+ if (known)
136
+ await remote(['update-ref', '--stdin'], `${known}\n`).catch(() => '');
137
+ await remote([
138
+ 'fetch',
139
+ '--no-tags',
140
+ '--no-write-fetch-head',
141
+ '-q',
142
+ auth.url,
143
+ ...wanted.map((b) => `+refs/heads/${b}:refs/remotes/origin/${b}`),
144
+ ]);
145
+ const out = [];
146
+ for (const name of wanted) {
147
+ // What the fetch wrote, not ls-remote's answer: the remote may have moved since.
148
+ const sha = await remote(['rev-parse', `refs/remotes/origin/${name}`]);
149
+ await inClone(common, env, 'update-ref', `refs/remotes/origin/${name}`, sha);
150
+ out.push({ name, sha });
151
+ }
152
+ return out;
153
+ });
154
+ }
155
+ /**
156
+ * Push the clone's `refs/heads/<branch>` to the remote's branch of the same
157
+ * name. Without a lease only a fast-forward lands; with one, the remote must
158
+ * still be at the expected SHA (absent remote-tracking ref: no branch).
159
+ * Returns the pushed SHA and records it as refs/remotes/origin/<branch>.
160
+ */
161
+ export async function pushBranch(worktree, auth, branch, lease) {
162
+ return withScratch(worktree, auth, async ({ common, env, remote }) => {
163
+ const sha = await readRef(common, env, `refs/heads/${branch}`);
164
+ if (!sha)
165
+ throw new Error(`no local branch '${branch}' to push`);
166
+ const args = ['push', '--porcelain', '--no-verify'];
167
+ if (lease.forceWithLease) {
168
+ const expected = lease.expectedSha ??
169
+ (await readRef(common, env, `refs/remotes/origin/${branch}`)) ??
170
+ '';
171
+ args.push(`--force-with-lease=refs/heads/${branch}:${expected}`);
172
+ }
173
+ await remote([...args, auth.url, `${sha}:refs/heads/${branch}`]);
174
+ await inClone(common, env, 'update-ref', `refs/remotes/origin/${branch}`, sha);
175
+ return sha;
176
+ });
177
+ }
@@ -0,0 +1,5 @@
1
+ import type { Preset } from '@bakers-build/conductor/contract';
2
+ import type { BbPreset } from '@bakers-build/core';
3
+ export declare const scopes: BbPreset['scopes'];
4
+ export { mergeWith } from './config.js';
5
+ export declare const tools: Preset['tools'];
@@ -0,0 +1,13 @@
1
+ // The forge addon: five conductor tools a sandboxed run delivers through,
2
+ // while the token stays with the conductor. Without a forge entry no tool is
3
+ // served and runs deliver as before.
4
+ import { FORGE_TOOL_DEFINITIONS } from './tool-definitions.js';
5
+ // Configurable on either level; the machine entry's with wins.
6
+ export const scopes = ['machine', 'project'];
7
+ // Merge the entry's own settings across scopes; options.schema.json describes them.
8
+ export { mergeWith } from './config.js';
9
+ export const tools = {
10
+ contract: 1,
11
+ tools: FORGE_TOOL_DEFINITIONS,
12
+ handlers: (options) => import('./tools.js').then((m) => m.toolsFor(options)),
13
+ };
@@ -0,0 +1,14 @@
1
+ /** `RULE` of `tests/contract/mr-title.sh`, verbatim (a test keeps them equal). */
2
+ export declare const CONVENTIONAL_TITLE_RULE = "^(feat|fix|chore|docs|refactor|test|ci|build|perf)(\\([a-z0-9._/-]+\\))?!?: [^[:space:]].*$";
3
+ export declare function isConventionalTitle(title: string): boolean;
4
+ export declare const TITLE_REFUSAL = "the title must be a Conventional Commits subject `type(scope)!: subject`, type one of feat, fix, chore, docs, refactor, test, ci, build, perf (e.g. `feat(cli): forge tools`)";
5
+ /** The run's own branch, or `<branch_name>-<suffix>`. */
6
+ export declare function isOwned(branch: string, branchName: string): boolean;
7
+ export declare function ownershipRefusal(branch: string, branchName: string): string;
8
+ /** The ticket branch merges into `base_branch`; a variant into the ticket branch. */
9
+ export declare function derivedBase(branch: string, run: {
10
+ branchName: string;
11
+ baseBranch: string;
12
+ }): string;
13
+ /** Remove every secret and any Authorization header value. */
14
+ export declare function redact(text: string, secrets: readonly string[]): string;
@@ -0,0 +1,38 @@
1
+ // The forge tools' pure rules: which branches a run owns, where each merges,
2
+ // which PR/MR titles pass, and what never leaves a tool result.
3
+ /** `RULE` of `tests/contract/mr-title.sh`, verbatim (a test keeps them equal). */
4
+ export const CONVENTIONAL_TITLE_RULE = '^(feat|fix|chore|docs|refactor|test|ci|build|perf)(\\([a-z0-9._/-]+\\))?!?: [^[:space:]].*$';
5
+ const TITLE = new RegExp(CONVENTIONAL_TITLE_RULE.replace('[^[:space:]]', '\\S'));
6
+ export function isConventionalTitle(title) {
7
+ return TITLE.test(title);
8
+ }
9
+ export const TITLE_REFUSAL = 'the title must be a Conventional Commits subject `type(scope)!: subject`, type one of feat, fix, chore, docs, refactor, test, ci, build, perf (e.g. `feat(cli): forge tools`)';
10
+ /** One ref component: no slash, no `..`, no trailing `.` or `.lock`. */
11
+ const SUFFIX = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
12
+ /** The run's own branch, or `<branch_name>-<suffix>`. */
13
+ export function isOwned(branch, branchName) {
14
+ if (branch === branchName)
15
+ return true;
16
+ if (!branch.startsWith(`${branchName}-`))
17
+ return false;
18
+ const suffix = branch.slice(branchName.length + 1);
19
+ return (SUFFIX.test(suffix) &&
20
+ !suffix.includes('..') &&
21
+ !suffix.endsWith('.') &&
22
+ !suffix.endsWith('.lock'));
23
+ }
24
+ export function ownershipRefusal(branch, branchName) {
25
+ return `branch '${branch}' is not owned by this run: only '${branchName}' and '${branchName}-<suffix>' may be used`;
26
+ }
27
+ /** The ticket branch merges into `base_branch`; a variant into the ticket branch. */
28
+ export function derivedBase(branch, run) {
29
+ return branch === run.branchName ? run.baseBranch : run.branchName;
30
+ }
31
+ /** Remove every secret and any Authorization header value. */
32
+ export function redact(text, secrets) {
33
+ let out = text.replace(/(authorization["']?:\s*["']?)(?:\w+\s+)?[^\s"',]+/gi, '$1[redacted]');
34
+ for (const secret of secrets)
35
+ if (secret.length >= 4)
36
+ out = out.split(secret).join('[redacted]');
37
+ return out;
38
+ }
@@ -0,0 +1,2 @@
1
+ import type { ToolDefinition } from '@bakers-build/conductor/contract';
2
+ export declare const FORGE_TOOL_DEFINITIONS: Record<string, ToolDefinition>;
@@ -0,0 +1,58 @@
1
+ // The forge tools' definitions, apart from their handlers so the preset stays
2
+ // import-light. No tool takes a ticket, a base or a target: those come from
3
+ // the calling run.
4
+ const branch = {
5
+ type: 'string',
6
+ description: "an owned branch: the ticket's branch_name (default) or <branch_name>-<suffix>",
7
+ };
8
+ export const FORGE_TOOL_DEFINITIONS = {
9
+ forge_fetch: {
10
+ description: "Fetch the ticket's base_branch, its own branch and the named parent branches into refs/remotes/origin/*; read only. Then rebase or merge locally with git.",
11
+ input: {
12
+ type: 'object',
13
+ properties: {
14
+ branches: {
15
+ type: 'string',
16
+ description: 'further branches to fetch, space-separated, e.g. those of parent-linked tickets',
17
+ },
18
+ },
19
+ },
20
+ },
21
+ forge_push: {
22
+ description: "Push an owned branch's local head. Fast-forward only, unless force_with_lease: then the remote must still be at expected_sha (default: refs/remotes/origin/<branch>). Bare force does not exist.",
23
+ input: {
24
+ type: 'object',
25
+ properties: {
26
+ branch,
27
+ force_with_lease: { type: 'boolean' },
28
+ expected_sha: { type: 'string', pattern: '^[0-9a-f]{40}$' },
29
+ },
30
+ },
31
+ },
32
+ forge_pr_upsert: {
33
+ description: "Create the PR/MR of an owned branch into its base (base_branch, or the ticket branch for a variant), or update the open one's title and body. The title must be a Conventional Commits subject.",
34
+ input: {
35
+ type: 'object',
36
+ properties: {
37
+ branch,
38
+ title: { type: 'string' },
39
+ body: { type: 'string' },
40
+ },
41
+ required: ['title'],
42
+ },
43
+ },
44
+ forge_checks: {
45
+ description: "Status of each check or pipeline job, and whether it is required, on the head of an owned branch's PR/MR, or on any `ref` (branch or SHA); read only, one shot.",
46
+ input: {
47
+ type: 'object',
48
+ properties: {
49
+ branch,
50
+ ref: { type: 'string', description: 'any branch or SHA, read only' },
51
+ },
52
+ },
53
+ },
54
+ forge_merge: {
55
+ description: "Merge the open PR/MR of an owned branch into its base, once it is conflict free and every required check is green. Human approval is the workflow's merge gate, not this tool's.",
56
+ input: { type: 'object', properties: { branch } },
57
+ },
58
+ };
@@ -0,0 +1,13 @@
1
+ import type { ToolHandler } from '@bakers-build/conductor/contract';
2
+ import type { ForgeBackend, ForgeCheck } from './backend.js';
3
+ export declare const FORGE_NEEDS_RUN = "forge tools act only for an agent run: the call carries no ticket branch_name and base_branch (an operator delivers by hand)";
4
+ export interface ForgeToolDeps {
5
+ backend(): Promise<ForgeBackend>;
6
+ /** The forge entry's `with` (merge rules). */
7
+ settings: unknown;
8
+ }
9
+ /** Failure first, then pending, over the required checks. */
10
+ export declare function overall(checks: readonly ForgeCheck[]): ForgeCheck['status'];
11
+ export declare function forgeTools(deps: ForgeToolDeps): Record<string, ToolHandler>;
12
+ /** The live tools for the conductor's effective forge entry `with`. */
13
+ export declare function toolsFor(options: unknown): Record<string, ToolHandler>;
@@ -0,0 +1,182 @@
1
+ // The forge tools. Every call takes the ticket, branch_name and base_branch
2
+ // from the calling run; the token stays with the backend and is redacted
3
+ // from every result and error.
4
+ import { homeBbDir } from '@bakers-build/core';
5
+ import { FORGE_NEEDS_BACKEND, loadForgeBackend, mergeRule } from './config.js';
6
+ import { fetchBranches, pushBranch } from './git.js';
7
+ import { derivedBase, isConventionalTitle, isOwned, ownershipRefusal, redact, TITLE_REFUSAL, } from './rules.js';
8
+ export const FORGE_NEEDS_RUN = 'forge tools act only for an agent run: the call carries no ticket branch_name and base_branch (an operator delivers by hand)';
9
+ function runOf(req) {
10
+ const t = req.ticket;
11
+ if (!t?.branchName || !t.baseBranch)
12
+ throw new Error(FORGE_NEEDS_RUN);
13
+ return {
14
+ identifier: t.identifier,
15
+ branchName: t.branchName,
16
+ baseBranch: t.baseBranch,
17
+ };
18
+ }
19
+ function ownedBranch(req, run) {
20
+ const branch = req.args.branch ?? run.branchName;
21
+ if (!isOwned(branch, run.branchName))
22
+ throw new Error(ownershipRefusal(branch, run.branchName));
23
+ return branch;
24
+ }
25
+ const json = (value) => ({
26
+ text: JSON.stringify(value, null, 2),
27
+ });
28
+ /** Failure first, then pending, over the required checks. */
29
+ export function overall(checks) {
30
+ const required = checks.filter((c) => c.required);
31
+ if (required.some((c) => c.status === 'failure'))
32
+ return 'failure';
33
+ if (required.some((c) => c.status !== 'success'))
34
+ return 'pending';
35
+ return 'success';
36
+ }
37
+ export function forgeTools(deps) {
38
+ let loaded;
39
+ const backend = async () => (loaded = await deps.backend());
40
+ // Read at redaction time: a backend may mint or refresh its token mid-call.
41
+ const secrets = () => loaded?.secrets() ?? [];
42
+ // Every result and error leaves through the redactor.
43
+ const guarded = (handler) => async (req) => {
44
+ try {
45
+ const result = await handler(req);
46
+ return { text: redact(result.text, secrets()) };
47
+ }
48
+ catch (err) {
49
+ throw new Error(redact(err instanceof Error ? err.message : String(err), secrets()));
50
+ }
51
+ };
52
+ const tools = {
53
+ forge_fetch: async (req) => {
54
+ const run = runOf(req);
55
+ const extra = String(req.args.branches ?? '')
56
+ .split(/\s+/)
57
+ .filter(Boolean);
58
+ const b = await backend();
59
+ const refs = await fetchBranches(req.worktreePath, await b.gitAuth(), [
60
+ run.baseBranch,
61
+ run.branchName,
62
+ ...extra,
63
+ ]);
64
+ return json({ fetched: refs });
65
+ },
66
+ forge_push: async (req) => {
67
+ const run = runOf(req);
68
+ const branch = ownedBranch(req, run);
69
+ const b = await backend();
70
+ const sha = await pushBranch(req.worktreePath, await b.gitAuth(), branch, {
71
+ forceWithLease: req.args.force_with_lease === true,
72
+ ...(typeof req.args.expected_sha === 'string'
73
+ ? { expectedSha: req.args.expected_sha }
74
+ : {}),
75
+ });
76
+ return json({ branch, sha });
77
+ },
78
+ forge_pr_upsert: async (req) => {
79
+ const title = String(req.args.title);
80
+ // Refused before any request reaches the forge.
81
+ if (!isConventionalTitle(title))
82
+ throw new Error(TITLE_REFUSAL);
83
+ const run = runOf(req);
84
+ const branch = ownedBranch(req, run);
85
+ const body = req.args.body ?? '';
86
+ const b = await backend();
87
+ const open = await b.findOpenPr(branch);
88
+ const pr = open
89
+ ? await b.updatePr(open, { title, body })
90
+ : await b.createPr({
91
+ head: branch,
92
+ base: derivedBase(branch, run),
93
+ title,
94
+ body,
95
+ });
96
+ return json({
97
+ url: pr.url,
98
+ number: pr.number,
99
+ target: pr.base,
100
+ created: !open,
101
+ });
102
+ },
103
+ forge_checks: async (req) => {
104
+ const run = runOf(req);
105
+ const b = await backend();
106
+ let ref;
107
+ let base = run.baseBranch;
108
+ if (typeof req.args.ref === 'string' && req.args.branch === undefined) {
109
+ ref = req.args.ref;
110
+ }
111
+ else {
112
+ const branch = ownedBranch(req, run);
113
+ const pr = await b.findOpenPr(branch);
114
+ ref = pr?.headSha ?? branch;
115
+ base = pr?.base ?? derivedBase(branch, run);
116
+ }
117
+ const checks = await b.checks(ref, base);
118
+ return json({ ref, base, overall: overall(checks), checks });
119
+ },
120
+ forge_merge: async (req) => {
121
+ const run = runOf(req);
122
+ const branch = ownedBranch(req, run);
123
+ const base = derivedBase(branch, run);
124
+ const b = await backend();
125
+ const pr = await b.findOpenPr(branch);
126
+ if (!pr)
127
+ throw new Error(`no open PR/MR from '${branch}'; open it with forge_pr_upsert`);
128
+ if (pr.base !== base)
129
+ throw new Error(`PR/MR ${pr.url} targets '${pr.base}', but '${branch}' merges into '${base}'; close it and open one into '${base}'`);
130
+ if (!isConventionalTitle(pr.title))
131
+ throw new Error(`PR/MR ${pr.url}: ${TITLE_REFUSAL}; retitle it with forge_pr_upsert`);
132
+ const blocker = await b.mergeBlocker(pr);
133
+ if (blocker)
134
+ throw new Error(`PR/MR ${pr.url} cannot merge: ${blocker}`);
135
+ const checks = await b.checks(pr.headSha, pr.base);
136
+ const notGreen = checks.filter((c) => c.required && c.status !== 'success');
137
+ if (notGreen.length > 0)
138
+ throw new Error(`PR/MR ${pr.url} has required checks that are not green: ${notGreen.map((c) => `${c.name} (${c.status})`).join(', ')}`);
139
+ const rule = mergeRule(deps.settings, base);
140
+ const dependents = (await b.openPrsWithBase(branch)).filter((p) => p.number !== pr.number);
141
+ const deleteSource = rule.delete_source && dependents.length === 0;
142
+ await b.merge(pr, {
143
+ method: rule.method,
144
+ deleteSource,
145
+ subject: pr.title,
146
+ body: `Merged-by: bakers.build\nref:${run.identifier}`,
147
+ });
148
+ if (!(await b.isMerged(pr)))
149
+ throw new Error(`PR/MR ${pr.url} does not report merged after the merge call`);
150
+ return json({
151
+ url: pr.url,
152
+ merged: true,
153
+ into: base,
154
+ method: rule.method,
155
+ source_deleted: deleteSource,
156
+ ...(dependents.length > 0
157
+ ? { source_kept_for: dependents.map((p) => p.url) }
158
+ : {}),
159
+ });
160
+ },
161
+ };
162
+ return Object.fromEntries(Object.entries(tools).map(([n, h]) => [n, guarded(h)]));
163
+ }
164
+ /** The live tools for the conductor's effective forge entry `with`. */
165
+ export function toolsFor(options) {
166
+ const backend = options
167
+ ?.backend;
168
+ // Fail closed: a forge entry without a backend stops the conductor's start.
169
+ if (typeof backend?.use !== 'string' || backend.use === '')
170
+ throw new Error(FORGE_NEEDS_BACKEND);
171
+ let loaded;
172
+ return forgeTools({
173
+ settings: options,
174
+ backend: () => {
175
+ loaded ??= loadForgeBackend(options, homeBbDir()).catch((err) => {
176
+ loaded = undefined;
177
+ throw err;
178
+ });
179
+ return loaded;
180
+ },
181
+ });
182
+ }
@@ -0,0 +1,53 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://bakers.build/schema/addon-forge/options.schema.json",
4
+ "title": "@bakers-build/addon-forge options",
5
+ "description": "The forge entry's with.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "backend"
10
+ ],
11
+ "properties": {
12
+ "backend": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "required": [
16
+ "use"
17
+ ],
18
+ "properties": {
19
+ "use": {
20
+ "type": "string",
21
+ "minLength": 1,
22
+ "description": "The backend package."
23
+ },
24
+ "with": {
25
+ "description": "The backend package's own settings, checked by that package's options.schema.json."
26
+ }
27
+ }
28
+ },
29
+ "merge": {
30
+ "type": "object",
31
+ "description": "Merge rule per base branch; '*' for every other base.",
32
+ "additionalProperties": {
33
+ "type": "object",
34
+ "additionalProperties": false,
35
+ "required": [
36
+ "method",
37
+ "delete_source"
38
+ ],
39
+ "properties": {
40
+ "method": {
41
+ "enum": [
42
+ "squash",
43
+ "merge"
44
+ ]
45
+ },
46
+ "delete_source": {
47
+ "type": "boolean"
48
+ }
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-forge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.8.1-next.2",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ "./preset": "./dist/src/preset.js",
9
+ "./backend": "./dist/src/backend.js",
10
+ "./options.schema.json": "./options.schema.json"
11
+ },
12
+ "files": [
13
+ "dist/src",
14
+ "options.schema.json"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/bakers-build/bakers-build.git",
22
+ "directory": "bakers-cli/addons/forge"
23
+ },
24
+ "peerDependencies": {
25
+ "@bakers-build/conductor": "^0.8.1-next.2",
26
+ "@bakers-build/core": "^0.8.1-next.2"
27
+ },
28
+ "bakersBuild": {
29
+ "commit": "0177880667693d1c36c65ffced82d2f3a17cbf2a",
30
+ "channel": "next"
31
+ }
32
+ }