balladeer 0.0.4 → 1.0.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/LICENSE +200 -5
- package/README.md +154 -68
- package/dist/agent.d.ts +126 -0
- package/dist/agent.js +209 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.js +392 -0
- package/dist/client.d.ts +44 -0
- package/dist/client.js +114 -0
- package/dist/commands/affected.d.ts +22 -0
- package/dist/commands/affected.js +122 -0
- package/dist/commands/check-seals.d.ts +37 -0
- package/dist/commands/check-seals.js +289 -0
- package/dist/commands/discover.d.ts +68 -0
- package/dist/commands/discover.js +395 -0
- package/dist/commands/explain.d.ts +35 -0
- package/dist/commands/explain.js +90 -0
- package/dist/commands/invite.d.ts +24 -0
- package/dist/commands/invite.js +197 -0
- package/dist/commands/mcp.d.ts +65 -0
- package/dist/commands/mcp.js +202 -0
- package/dist/commands/propose.d.ts +59 -0
- package/dist/commands/propose.js +262 -0
- package/dist/commands/repositories.d.ts +18 -0
- package/dist/commands/repositories.js +185 -0
- package/dist/commands/setup.d.ts +75 -0
- package/dist/commands/setup.js +1471 -0
- package/dist/commands/status.d.ts +35 -0
- package/dist/commands/status.js +482 -0
- package/dist/commands/touch-map.d.ts +42 -0
- package/dist/commands/touch-map.js +251 -0
- package/dist/commands/whoami.d.ts +8 -0
- package/dist/commands/whoami.js +79 -0
- package/dist/conventions.d.ts +69 -0
- package/dist/conventions.js +175 -0
- package/dist/copy.d.ts +148 -0
- package/dist/copy.js +459 -0
- package/dist/currency.d.ts +31 -0
- package/dist/currency.js +72 -0
- package/dist/gh.d.ts +80 -0
- package/dist/gh.js +188 -0
- package/dist/git.d.ts +76 -0
- package/dist/git.js +203 -0
- package/dist/markers.d.ts +76 -0
- package/dist/markers.js +125 -0
- package/dist/mcp-config.d.ts +99 -0
- package/dist/mcp-config.js +230 -0
- package/dist/release.d.ts +55 -0
- package/dist/release.js +67 -0
- package/dist/repository.d.ts +8 -0
- package/dist/repository.js +32 -0
- package/dist/seals.d.ts +48 -0
- package/dist/seals.js +112 -0
- package/dist/store.d.ts +98 -0
- package/dist/store.js +225 -0
- package/dist/touch-map.d.ts +241 -0
- package/dist/touch-map.js +487 -0
- package/dist/wire.d.ts +588 -0
- package/dist/wire.js +20 -0
- package/package.json +19 -10
- package/bin/balladeer.js +0 -136
package/dist/gh.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* The person's own GitHub access, used through the tool they already signed in
|
|
4
|
+
* to. Balladeer holds no GitHub token and this command never sends one anywhere.
|
|
5
|
+
*
|
|
6
|
+
* Every call goes through `execFile` with an argument array, never a shell, so
|
|
7
|
+
* nothing here can be turned into a command by a repository name. Standard error
|
|
8
|
+
* is captured rather than inherited, because a `gh` failure has to become one
|
|
9
|
+
* plain sentence with a next action rather than provider noise in the middle of
|
|
10
|
+
* a step.
|
|
11
|
+
*/
|
|
12
|
+
const TIMEOUT_MS = 20_000;
|
|
13
|
+
export function runCommand(file, args, options = {}) {
|
|
14
|
+
return new Promise((done) => {
|
|
15
|
+
execFile(file, [...args], {
|
|
16
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
17
|
+
...(options.env === undefined ? {} : { env: { ...options.env } }),
|
|
18
|
+
timeout: options.timeoutMs ?? TIMEOUT_MS,
|
|
19
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
20
|
+
windowsHide: true,
|
|
21
|
+
}, (error, stdout, stderr) => {
|
|
22
|
+
const code = error === null ? 0 : typeof error.code === "number" ? error.code : 1;
|
|
23
|
+
done({
|
|
24
|
+
ok: error === null,
|
|
25
|
+
code,
|
|
26
|
+
stdout: String(stdout),
|
|
27
|
+
// Bounded: a refusal is a sentence, never a page of output.
|
|
28
|
+
stderr: String(stderr).trim().slice(0, 500),
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function readFacts(raw, authenticated) {
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(raw);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const id = typeof parsed.id === "number" ? String(parsed.id) : undefined;
|
|
42
|
+
const ownerId = typeof parsed.owner?.id === "number" ? String(parsed.owner.id) : undefined;
|
|
43
|
+
const branch = typeof parsed.default_branch === "string" ? parsed.default_branch : undefined;
|
|
44
|
+
if (!id || !ownerId || !branch)
|
|
45
|
+
return undefined;
|
|
46
|
+
// GitHub returns `permissions` only for an authenticated request, so its
|
|
47
|
+
// absence is exactly the case where nothing was proved about the caller.
|
|
48
|
+
const canWrite = parsed.permissions?.admin === true || parsed.permissions?.push === true;
|
|
49
|
+
return {
|
|
50
|
+
githubRepositoryId: id,
|
|
51
|
+
githubOwnerId: ownerId,
|
|
52
|
+
defaultBranch: branch,
|
|
53
|
+
canWrite,
|
|
54
|
+
authenticated,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export async function ghAvailable() {
|
|
58
|
+
return (await runCommand("gh", ["--version"])).ok;
|
|
59
|
+
}
|
|
60
|
+
export async function ghSignedIn() {
|
|
61
|
+
return (await runCommand("gh", ["auth", "status"])).ok;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Reads a repository's immutable ids, its default branch, and what the person
|
|
65
|
+
* may do with it.
|
|
66
|
+
*
|
|
67
|
+
* The unauthenticated read is deliberately NOT a fallback for enrolling. It
|
|
68
|
+
* proves nothing about who is asking, so a workspace could otherwise end up
|
|
69
|
+
* holding an enrollment, a CI identity, and eventually a protection claim about
|
|
70
|
+
* a repository nobody in it controls. It exists only so the command can render
|
|
71
|
+
* the do-it-by-hand block with real values.
|
|
72
|
+
*/
|
|
73
|
+
export async function readRepositoryFacts(repository) {
|
|
74
|
+
if (await ghAvailable()) {
|
|
75
|
+
if (!(await ghSignedIn())) {
|
|
76
|
+
return {
|
|
77
|
+
kind: "unavailable",
|
|
78
|
+
reason: "gh is installed but not signed in",
|
|
79
|
+
next: "Run `gh auth login`, then run this command again.",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const answer = await runCommand("gh", ["api", `repos/${repository}`]);
|
|
83
|
+
if (answer.ok) {
|
|
84
|
+
const facts = readFacts(answer.stdout, true);
|
|
85
|
+
if (facts)
|
|
86
|
+
return { kind: "found", facts };
|
|
87
|
+
return {
|
|
88
|
+
kind: "unavailable",
|
|
89
|
+
reason: "GitHub's answer did not carry the repository's ids",
|
|
90
|
+
next: "Check that the name is right and that GitHub is reachable, then run this command again.",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
kind: "unavailable",
|
|
95
|
+
reason: answer.stderr || `gh could not read repos/${repository}`,
|
|
96
|
+
next: `Check that ${repository} exists and that your GitHub account can see it, then run this command again.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
kind: "unavailable",
|
|
101
|
+
reason: "the GitHub CLI is not installed",
|
|
102
|
+
next: "Install the GitHub CLI from https://cli.github.com, sign in with `gh auth login`, then run this command again.",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The public read is a display convenience with no next action of its own: the
|
|
107
|
+
* caller is already printing the by-hand block, and this only fills its values.
|
|
108
|
+
*/
|
|
109
|
+
const PUBLIC_NEXT = "Add the repository by hand instead.";
|
|
110
|
+
/** The public read, for rendering the fallback block only. */
|
|
111
|
+
export async function readPublicRepositoryFacts(repository) {
|
|
112
|
+
let response;
|
|
113
|
+
try {
|
|
114
|
+
response = await fetch(`https://api.github.com/repos/${repository}`, {
|
|
115
|
+
headers: { accept: "application/vnd.github+json" },
|
|
116
|
+
redirect: "manual",
|
|
117
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return { kind: "unavailable", reason: "GitHub could not be reached", next: PUBLIC_NEXT };
|
|
122
|
+
}
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
return {
|
|
125
|
+
kind: "unavailable",
|
|
126
|
+
reason: `GitHub answered ${response.status}`,
|
|
127
|
+
next: PUBLIC_NEXT,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const facts = readFacts(await response.text(), false);
|
|
131
|
+
return facts === undefined
|
|
132
|
+
? {
|
|
133
|
+
kind: "unavailable",
|
|
134
|
+
reason: "GitHub's answer did not carry the repository's ids",
|
|
135
|
+
next: PUBLIC_NEXT,
|
|
136
|
+
}
|
|
137
|
+
: { kind: "found", facts };
|
|
138
|
+
}
|
|
139
|
+
const WRITE_PERMISSIONS = new Set(["ADMIN", "MAINTAIN", "WRITE"]);
|
|
140
|
+
function readRepositoryList(raw) {
|
|
141
|
+
let parsed;
|
|
142
|
+
try {
|
|
143
|
+
parsed = JSON.parse(raw);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
if (!Array.isArray(parsed))
|
|
149
|
+
return [];
|
|
150
|
+
const rows = [];
|
|
151
|
+
for (const entry of parsed) {
|
|
152
|
+
const name = typeof entry?.nameWithOwner === "string" ? entry.nameWithOwner : undefined;
|
|
153
|
+
if (name === undefined || entry.isArchived === true)
|
|
154
|
+
continue;
|
|
155
|
+
const permission = typeof entry.viewerPermission === "string" ? entry.viewerPermission.toUpperCase() : "";
|
|
156
|
+
rows.push({ repository: name, canWrite: WRITE_PERMISSIONS.has(permission) });
|
|
157
|
+
}
|
|
158
|
+
return rows;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The repositories one GitHub account or organization holds, newest first.
|
|
162
|
+
*
|
|
163
|
+
* `owner` empty means the signed-in account's own. A refusal is an empty list
|
|
164
|
+
* rather than an error: this is a convenience for offering a choice, and a
|
|
165
|
+
* person who cannot list an organization can still name a repository by hand.
|
|
166
|
+
*/
|
|
167
|
+
export async function listVisibleRepositories(owner, limit = 100) {
|
|
168
|
+
const answer = await runCommand("gh", [
|
|
169
|
+
"repo",
|
|
170
|
+
"list",
|
|
171
|
+
...(owner === undefined ? [] : [owner]),
|
|
172
|
+
"--limit",
|
|
173
|
+
String(limit),
|
|
174
|
+
"--no-archived",
|
|
175
|
+
"--json",
|
|
176
|
+
"nameWithOwner,isArchived,viewerPermission",
|
|
177
|
+
]);
|
|
178
|
+
return answer.ok ? readRepositoryList(answer.stdout) : [];
|
|
179
|
+
}
|
|
180
|
+
/** The signed-in GitHub account's login, or nothing. */
|
|
181
|
+
export async function ghLogin() {
|
|
182
|
+
const answer = await runCommand("gh", ["api", "user", "--jq", ".login"]);
|
|
183
|
+
const login = answer.stdout.trim();
|
|
184
|
+
return answer.ok && /^[A-Za-z0-9-]{1,39}$/.test(login) ? login : undefined;
|
|
185
|
+
}
|
|
186
|
+
export async function setRepositoryVariable(repository, name, value) {
|
|
187
|
+
return runCommand("gh", ["variable", "set", name, "--repo", repository, "--body", value]);
|
|
188
|
+
}
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export declare const CI_BRANCH = "balladeer/connect-ci";
|
|
2
|
+
export declare function repositoryRoot(cwd: string): Promise<string | undefined>;
|
|
3
|
+
/**
|
|
4
|
+
* A repository in the middle of a merge or a rebase is not a repository to open
|
|
5
|
+
* a pull request from. The person is mid-operation, and anything this command
|
|
6
|
+
* does to it will land in the middle of that work.
|
|
7
|
+
*/
|
|
8
|
+
export declare function midOperation(root: string): Promise<boolean>;
|
|
9
|
+
export type BranchResult = Readonly<{
|
|
10
|
+
kind: "opened";
|
|
11
|
+
pullRequestUrl: string | undefined;
|
|
12
|
+
}>
|
|
13
|
+
/**
|
|
14
|
+
* The branch was already open with a pull request on it, and now carries the
|
|
15
|
+
* new bytes. This is what a release move looks like where the first pull
|
|
16
|
+
* request has not merged yet: one pull request that moves, rather than a
|
|
17
|
+
* second one competing to change the same file.
|
|
18
|
+
*/
|
|
19
|
+
| Readonly<{
|
|
20
|
+
kind: "updated";
|
|
21
|
+
pullRequestUrl: string | undefined;
|
|
22
|
+
}>
|
|
23
|
+
/** The branch already carries these exact bytes, so there was nothing to commit. */
|
|
24
|
+
| Readonly<{
|
|
25
|
+
kind: "unchanged";
|
|
26
|
+
}> | Readonly<{
|
|
27
|
+
kind: "refused";
|
|
28
|
+
reason: string;
|
|
29
|
+
}>;
|
|
30
|
+
/** The one-line subject of the commit and the pull request this branch carries. */
|
|
31
|
+
export declare const CI_COMMIT_SUBJECT = "Connect Balladeer CI";
|
|
32
|
+
/**
|
|
33
|
+
* The workflow as the default branch currently has it, fetched fresh.
|
|
34
|
+
*
|
|
35
|
+
* This is what tells a re-run that the pull request was merged. GitHub deletes
|
|
36
|
+
* the head branch on merge in most repositories, so looking for that branch
|
|
37
|
+
* answers "no" for a repository where the work is finished, and the command
|
|
38
|
+
* would go on to cut the branch again, write identical bytes, and be told by git
|
|
39
|
+
* that there is nothing to commit. A person would then read an instruction to
|
|
40
|
+
* commit a file that is already on their default branch.
|
|
41
|
+
*/
|
|
42
|
+
export declare function workflowOnDefaultBranch(root: string, defaultBranch: string, workflowPath: string): Promise<string | undefined>;
|
|
43
|
+
/**
|
|
44
|
+
* Commits ONE file on a branch cut from the freshly fetched default branch, in a
|
|
45
|
+
* throwaway worktree.
|
|
46
|
+
*
|
|
47
|
+
* Three properties, each the answer to a way this could have gone wrong:
|
|
48
|
+
*
|
|
49
|
+
* - The branch is cut from `origin/<default branch>` as just fetched, never from
|
|
50
|
+
* whatever HEAD happens to be. A developer sitting on a feature branch would
|
|
51
|
+
* otherwise open a pull request carrying every local commit they had not
|
|
52
|
+
* pushed, including anything they had not meant to. Where the branch is
|
|
53
|
+
* already open on the remote it is BUILT ON instead, so a second commit lands
|
|
54
|
+
* on the pull request the reviewer is already looking at. Re-cutting it from
|
|
55
|
+
* the default branch would rewrite a pushed branch, which the remote refuses,
|
|
56
|
+
* and forcing past that refusal would discard whatever the reviewer had asked
|
|
57
|
+
* for on it.
|
|
58
|
+
* - It is a separate worktree, so the developer's own checkout is never switched,
|
|
59
|
+
* never stashed, and never left on a branch they did not ask for. Their
|
|
60
|
+
* uncommitted work stays exactly where it was.
|
|
61
|
+
* - The commit names its path explicitly. The `.mcp.json` and instructions
|
|
62
|
+
* changes from the previous step stay in the developer's working tree for them
|
|
63
|
+
* to commit deliberately: a pull request titled "Connect Balladeer CI" must
|
|
64
|
+
* contain the CI change and nothing else, because its reviewer is approving
|
|
65
|
+
* what they can see.
|
|
66
|
+
*/
|
|
67
|
+
export declare function commitWorkflowOnBranch(input: {
|
|
68
|
+
root: string;
|
|
69
|
+
defaultBranch: string;
|
|
70
|
+
workflowPath: string;
|
|
71
|
+
workflowYaml: string;
|
|
72
|
+
repository: string;
|
|
73
|
+
bodyPath: string;
|
|
74
|
+
/** What the commit and any new pull request are called. */
|
|
75
|
+
subject?: string;
|
|
76
|
+
}): Promise<BranchResult>;
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { runCommand } from "./gh.js";
|
|
6
|
+
export const CI_BRANCH = "balladeer/connect-ci";
|
|
7
|
+
export async function repositoryRoot(cwd) {
|
|
8
|
+
const answer = await runCommand("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
9
|
+
return answer.ok ? answer.stdout.trim() : undefined;
|
|
10
|
+
}
|
|
11
|
+
async function gitDirectory(root) {
|
|
12
|
+
const answer = await runCommand("git", ["-C", root, "rev-parse", "--absolute-git-dir"]);
|
|
13
|
+
return answer.ok ? answer.stdout.trim() : undefined;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A repository in the middle of a merge or a rebase is not a repository to open
|
|
17
|
+
* a pull request from. The person is mid-operation, and anything this command
|
|
18
|
+
* does to it will land in the middle of that work.
|
|
19
|
+
*/
|
|
20
|
+
export async function midOperation(root) {
|
|
21
|
+
const directory = await gitDirectory(root);
|
|
22
|
+
if (directory === undefined)
|
|
23
|
+
return false;
|
|
24
|
+
return ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD"].some((marker) => existsSync(join(directory, marker)));
|
|
25
|
+
}
|
|
26
|
+
/** The one-line subject of the commit and the pull request this branch carries. */
|
|
27
|
+
export const CI_COMMIT_SUBJECT = "Connect Balladeer CI";
|
|
28
|
+
/**
|
|
29
|
+
* The workflow as the default branch currently has it, fetched fresh.
|
|
30
|
+
*
|
|
31
|
+
* This is what tells a re-run that the pull request was merged. GitHub deletes
|
|
32
|
+
* the head branch on merge in most repositories, so looking for that branch
|
|
33
|
+
* answers "no" for a repository where the work is finished, and the command
|
|
34
|
+
* would go on to cut the branch again, write identical bytes, and be told by git
|
|
35
|
+
* that there is nothing to commit. A person would then read an instruction to
|
|
36
|
+
* commit a file that is already on their default branch.
|
|
37
|
+
*/
|
|
38
|
+
export async function workflowOnDefaultBranch(root, defaultBranch, workflowPath) {
|
|
39
|
+
const fetched = await runCommand("git", ["-C", root, "fetch", "origin", defaultBranch]);
|
|
40
|
+
if (!fetched.ok)
|
|
41
|
+
return undefined;
|
|
42
|
+
const shown = await runCommand("git", ["-C", root, "show", `FETCH_HEAD:${workflowPath}`]);
|
|
43
|
+
return shown.ok ? shown.stdout : undefined;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Commits ONE file on a branch cut from the freshly fetched default branch, in a
|
|
47
|
+
* throwaway worktree.
|
|
48
|
+
*
|
|
49
|
+
* Three properties, each the answer to a way this could have gone wrong:
|
|
50
|
+
*
|
|
51
|
+
* - The branch is cut from `origin/<default branch>` as just fetched, never from
|
|
52
|
+
* whatever HEAD happens to be. A developer sitting on a feature branch would
|
|
53
|
+
* otherwise open a pull request carrying every local commit they had not
|
|
54
|
+
* pushed, including anything they had not meant to. Where the branch is
|
|
55
|
+
* already open on the remote it is BUILT ON instead, so a second commit lands
|
|
56
|
+
* on the pull request the reviewer is already looking at. Re-cutting it from
|
|
57
|
+
* the default branch would rewrite a pushed branch, which the remote refuses,
|
|
58
|
+
* and forcing past that refusal would discard whatever the reviewer had asked
|
|
59
|
+
* for on it.
|
|
60
|
+
* - It is a separate worktree, so the developer's own checkout is never switched,
|
|
61
|
+
* never stashed, and never left on a branch they did not ask for. Their
|
|
62
|
+
* uncommitted work stays exactly where it was.
|
|
63
|
+
* - The commit names its path explicitly. The `.mcp.json` and instructions
|
|
64
|
+
* changes from the previous step stay in the developer's working tree for them
|
|
65
|
+
* to commit deliberately: a pull request titled "Connect Balladeer CI" must
|
|
66
|
+
* contain the CI change and nothing else, because its reviewer is approving
|
|
67
|
+
* what they can see.
|
|
68
|
+
*/
|
|
69
|
+
export async function commitWorkflowOnBranch(input) {
|
|
70
|
+
const subject = input.subject ?? CI_COMMIT_SUBJECT;
|
|
71
|
+
if (await midOperation(input.root)) {
|
|
72
|
+
return {
|
|
73
|
+
kind: "refused",
|
|
74
|
+
reason: "this repository is in the middle of a merge or rebase",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const fetched = await runCommand("git", [
|
|
78
|
+
"-C",
|
|
79
|
+
input.root,
|
|
80
|
+
"fetch",
|
|
81
|
+
"origin",
|
|
82
|
+
input.defaultBranch,
|
|
83
|
+
]);
|
|
84
|
+
if (!fetched.ok) {
|
|
85
|
+
return {
|
|
86
|
+
kind: "refused",
|
|
87
|
+
reason: fetched.stderr || `git could not fetch ${input.defaultBranch}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const defaultHead = await runCommand("git", ["-C", input.root, "rev-parse", "FETCH_HEAD"]);
|
|
91
|
+
if (!defaultHead.ok) {
|
|
92
|
+
return {
|
|
93
|
+
kind: "refused",
|
|
94
|
+
reason: defaultHead.stderr || `git could not read ${input.defaultBranch}`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// Resolved to an explicit commit, because the second fetch below moves
|
|
98
|
+
// FETCH_HEAD and the branch has to be cut from one of the two deliberately.
|
|
99
|
+
let base = defaultHead.stdout.trim();
|
|
100
|
+
const existingBranch = await runCommand("git", ["-C", input.root, "fetch", "origin", CI_BRANCH]);
|
|
101
|
+
if (existingBranch.ok) {
|
|
102
|
+
const branchHead = await runCommand("git", ["-C", input.root, "rev-parse", "FETCH_HEAD"]);
|
|
103
|
+
if (branchHead.ok)
|
|
104
|
+
base = branchHead.stdout.trim();
|
|
105
|
+
}
|
|
106
|
+
const worktree = join(mkdtempSync(join(tmpdir(), "balladeer-ci-")), "tree");
|
|
107
|
+
const added = await runCommand("git", [
|
|
108
|
+
"-C",
|
|
109
|
+
input.root,
|
|
110
|
+
"worktree",
|
|
111
|
+
"add",
|
|
112
|
+
"-B",
|
|
113
|
+
CI_BRANCH,
|
|
114
|
+
worktree,
|
|
115
|
+
base,
|
|
116
|
+
]);
|
|
117
|
+
if (!added.ok) {
|
|
118
|
+
return { kind: "refused", reason: added.stderr || "git could not create a working branch" };
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const file = join(worktree, input.workflowPath);
|
|
122
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
123
|
+
writeFileSync(file, input.workflowYaml, { encoding: "utf8", mode: 0o644 });
|
|
124
|
+
const staged = await runCommand("git", ["-C", worktree, "add", "--", input.workflowPath]);
|
|
125
|
+
if (!staged.ok)
|
|
126
|
+
return { kind: "refused", reason: staged.stderr || "git could not stage the workflow" };
|
|
127
|
+
// Nothing staged means the branch already carries these exact bytes. That is
|
|
128
|
+
// a finished state, not a failure, and committing is what git would refuse.
|
|
129
|
+
const pending = await runCommand("git", [
|
|
130
|
+
"-C",
|
|
131
|
+
worktree,
|
|
132
|
+
"diff",
|
|
133
|
+
"--cached",
|
|
134
|
+
"--quiet",
|
|
135
|
+
"--",
|
|
136
|
+
input.workflowPath,
|
|
137
|
+
]);
|
|
138
|
+
if (pending.ok)
|
|
139
|
+
return { kind: "unchanged" };
|
|
140
|
+
const committed = await runCommand("git", [
|
|
141
|
+
"-C",
|
|
142
|
+
worktree,
|
|
143
|
+
"-c",
|
|
144
|
+
"commit.gpgsign=false",
|
|
145
|
+
"commit",
|
|
146
|
+
"-m",
|
|
147
|
+
subject,
|
|
148
|
+
"--",
|
|
149
|
+
input.workflowPath,
|
|
150
|
+
]);
|
|
151
|
+
if (!committed.ok) {
|
|
152
|
+
return { kind: "refused", reason: committed.stderr || "git could not commit the workflow" };
|
|
153
|
+
}
|
|
154
|
+
const pushed = await runCommand("git", ["-C", worktree, "push", "-u", "origin", CI_BRANCH]);
|
|
155
|
+
if (!pushed.ok) {
|
|
156
|
+
return { kind: "refused", reason: pushed.stderr || `git could not push ${CI_BRANCH}` };
|
|
157
|
+
}
|
|
158
|
+
// Only where the branch was already on the remote is there a pull request to
|
|
159
|
+
// find. Asking on a branch this run just created would be one GitHub call
|
|
160
|
+
// whose answer is always "none".
|
|
161
|
+
if (existingBranch.ok) {
|
|
162
|
+
const open = await runCommand("gh", [
|
|
163
|
+
"pr",
|
|
164
|
+
"list",
|
|
165
|
+
"--repo",
|
|
166
|
+
input.repository,
|
|
167
|
+
"--head",
|
|
168
|
+
CI_BRANCH,
|
|
169
|
+
"--state",
|
|
170
|
+
"open",
|
|
171
|
+
"--json",
|
|
172
|
+
"url",
|
|
173
|
+
"--jq",
|
|
174
|
+
".[0].url",
|
|
175
|
+
]);
|
|
176
|
+
const url = /(https:\/\/[^\s]*\/pull\/\d+)/.exec(open.ok ? open.stdout : "");
|
|
177
|
+
if (url)
|
|
178
|
+
return { kind: "updated", pullRequestUrl: url[1] };
|
|
179
|
+
}
|
|
180
|
+
const opened = await runCommand("gh", [
|
|
181
|
+
"pr",
|
|
182
|
+
"create",
|
|
183
|
+
"--repo",
|
|
184
|
+
input.repository,
|
|
185
|
+
"--base",
|
|
186
|
+
input.defaultBranch,
|
|
187
|
+
"--head",
|
|
188
|
+
CI_BRANCH,
|
|
189
|
+
"--title",
|
|
190
|
+
subject,
|
|
191
|
+
"--body-file",
|
|
192
|
+
input.bodyPath,
|
|
193
|
+
]);
|
|
194
|
+
if (!opened.ok) {
|
|
195
|
+
return { kind: "refused", reason: opened.stderr || "gh could not open the pull request" };
|
|
196
|
+
}
|
|
197
|
+
const match = /(https:\/\/[^\s]*\/pull\/\d+)/.exec(opened.stdout);
|
|
198
|
+
return { kind: "opened", pullRequestUrl: match?.[1] };
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
await runCommand("git", ["-C", input.root, "worktree", "remove", "--force", worktree]);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of a promise's scope markers no longer name anything in this checkout.
|
|
3
|
+
*
|
|
4
|
+
* A rename is the ordinary way a promise stops being retrievable. Somebody
|
|
5
|
+
* moves `src/import/preview.ts` to `src/ingest/preview.ts`, the code keeps
|
|
6
|
+
* working, the check keeps passing, and the promise about it quietly stops
|
|
7
|
+
* overlapping any path an agent will ever name. Nothing on the server can see
|
|
8
|
+
* that: Balladeer holds no token for the repository and never reads a file, so
|
|
9
|
+
* the only place the fact exists is the working tree in front of the runner.
|
|
10
|
+
*
|
|
11
|
+
* So it is observed here, locally, and reported as attention rather than
|
|
12
|
+
* repaired. A marker is a piece of approved meaning; moving one is a semantic
|
|
13
|
+
* revision a named person makes, and a command that rewrote markers because a
|
|
14
|
+
* file moved would be editing what somebody agreed to on the strength of a
|
|
15
|
+
* `git mv`.
|
|
16
|
+
*
|
|
17
|
+
* What leaves this machine is nothing. The observation is printed where the
|
|
18
|
+
* person ran the command.
|
|
19
|
+
*/
|
|
20
|
+
/** How many missing markers one run reports, and how many it will even look at. */
|
|
21
|
+
export declare const MARKER_OBSERVATION_LIMITS: {
|
|
22
|
+
/** Markers checked on disk in one run. Beyond this the run says it stopped counting. */
|
|
23
|
+
readonly maxChecked: 500;
|
|
24
|
+
/** Promises named in the printed row. The rest are counted, not listed. */
|
|
25
|
+
readonly maxNamed: 5;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* A marker is a path when it names one.
|
|
29
|
+
*
|
|
30
|
+
* The grammar admits both `src/import/preview.ts` and `supplier-import-preview`.
|
|
31
|
+
* Only the first can be missing from a checkout; the second is a topic, and
|
|
32
|
+
* reporting it as a missing file would tell a person to go looking for
|
|
33
|
+
* something that was never a file.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isPathShapedMarker(marker: string): boolean;
|
|
36
|
+
export type MarkerObservation = Readonly<{
|
|
37
|
+
promiseId: string;
|
|
38
|
+
title: string;
|
|
39
|
+
/** The markers this promise names that are not in this checkout. */
|
|
40
|
+
missing: readonly string[];
|
|
41
|
+
}>;
|
|
42
|
+
export type MarkerObservationResult = Readonly<{
|
|
43
|
+
/** Promises with at least one missing path marker, in the order they were given. */
|
|
44
|
+
observations: readonly MarkerObservation[];
|
|
45
|
+
/** How many path-shaped markers were actually looked at. */
|
|
46
|
+
checked: number;
|
|
47
|
+
/** True when the run stopped at its ceiling and did not look at every marker. */
|
|
48
|
+
stoppedEarly: boolean;
|
|
49
|
+
}>;
|
|
50
|
+
export type MarkerSubject = Readonly<{
|
|
51
|
+
promiseId: string;
|
|
52
|
+
title: string;
|
|
53
|
+
surfaces?: readonly string[];
|
|
54
|
+
labels?: readonly string[];
|
|
55
|
+
}>;
|
|
56
|
+
/**
|
|
57
|
+
* Look for each promise's path markers in this working tree.
|
|
58
|
+
*
|
|
59
|
+
* Bounded twice over: it stops after `maxChecked` markers whatever remains, and
|
|
60
|
+
* it says that it stopped. A repository with a thousand promises must not turn
|
|
61
|
+
* `status` into a filesystem walk, and a run that silently examined the first
|
|
62
|
+
* few hundred would report "nothing missing" about a catalog it never read.
|
|
63
|
+
*/
|
|
64
|
+
export declare function observeMissingMarkers(repositoryRoot: string, subjects: readonly MarkerSubject[], limits?: Readonly<{
|
|
65
|
+
maxChecked: number;
|
|
66
|
+
}>, exists?: (path: string) => boolean): MarkerObservationResult;
|
|
67
|
+
/**
|
|
68
|
+
* The attention row a person reads, or nothing at all when every marker is
|
|
69
|
+
* still there.
|
|
70
|
+
*
|
|
71
|
+
* It names what to do and who does it: a marker is approved meaning, so the
|
|
72
|
+
* remedy is a revision somebody agrees to, never an edit this command makes.
|
|
73
|
+
*/
|
|
74
|
+
export declare function staleMarkerRow(result: MarkerObservationResult, limits?: Readonly<{
|
|
75
|
+
maxNamed: number;
|
|
76
|
+
}>): string | undefined;
|
package/dist/markers.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, normalize, sep } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Which of a promise's scope markers no longer name anything in this checkout.
|
|
5
|
+
*
|
|
6
|
+
* A rename is the ordinary way a promise stops being retrievable. Somebody
|
|
7
|
+
* moves `src/import/preview.ts` to `src/ingest/preview.ts`, the code keeps
|
|
8
|
+
* working, the check keeps passing, and the promise about it quietly stops
|
|
9
|
+
* overlapping any path an agent will ever name. Nothing on the server can see
|
|
10
|
+
* that: Balladeer holds no token for the repository and never reads a file, so
|
|
11
|
+
* the only place the fact exists is the working tree in front of the runner.
|
|
12
|
+
*
|
|
13
|
+
* So it is observed here, locally, and reported as attention rather than
|
|
14
|
+
* repaired. A marker is a piece of approved meaning; moving one is a semantic
|
|
15
|
+
* revision a named person makes, and a command that rewrote markers because a
|
|
16
|
+
* file moved would be editing what somebody agreed to on the strength of a
|
|
17
|
+
* `git mv`.
|
|
18
|
+
*
|
|
19
|
+
* What leaves this machine is nothing. The observation is printed where the
|
|
20
|
+
* person ran the command.
|
|
21
|
+
*/
|
|
22
|
+
/** How many missing markers one run reports, and how many it will even look at. */
|
|
23
|
+
export const MARKER_OBSERVATION_LIMITS = {
|
|
24
|
+
/** Markers checked on disk in one run. Beyond this the run says it stopped counting. */
|
|
25
|
+
maxChecked: 500,
|
|
26
|
+
/** Promises named in the printed row. The rest are counted, not listed. */
|
|
27
|
+
maxNamed: 5,
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* A marker is a path when it names one.
|
|
31
|
+
*
|
|
32
|
+
* The grammar admits both `src/import/preview.ts` and `supplier-import-preview`.
|
|
33
|
+
* Only the first can be missing from a checkout; the second is a topic, and
|
|
34
|
+
* reporting it as a missing file would tell a person to go looking for
|
|
35
|
+
* something that was never a file.
|
|
36
|
+
*/
|
|
37
|
+
export function isPathShapedMarker(marker) {
|
|
38
|
+
const trimmed = marker.trim();
|
|
39
|
+
if (trimmed === "")
|
|
40
|
+
return false;
|
|
41
|
+
if (trimmed.includes("/"))
|
|
42
|
+
return true;
|
|
43
|
+
return /^[^/]+\.[A-Za-z0-9]{1,8}$/.test(trimmed);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A marker refused rather than resolved.
|
|
47
|
+
*
|
|
48
|
+
* A marker that escapes the repository root, or that arrives absolute, is not a
|
|
49
|
+
* marker this checkout can answer for. It is skipped rather than reported
|
|
50
|
+
* missing: telling a person a path is gone because the command would not look
|
|
51
|
+
* outside their repository is a false alarm about their catalog.
|
|
52
|
+
*/
|
|
53
|
+
function resolveInside(root, marker) {
|
|
54
|
+
const cleaned = marker.trim().replaceAll("\\", "/");
|
|
55
|
+
if (cleaned === "" || isAbsolute(cleaned))
|
|
56
|
+
return undefined;
|
|
57
|
+
const full = normalize(join(root, cleaned));
|
|
58
|
+
const bounded = root.endsWith(sep) ? root : `${root}${sep}`;
|
|
59
|
+
return full === root || full.startsWith(bounded) ? full : undefined;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Look for each promise's path markers in this working tree.
|
|
63
|
+
*
|
|
64
|
+
* Bounded twice over: it stops after `maxChecked` markers whatever remains, and
|
|
65
|
+
* it says that it stopped. A repository with a thousand promises must not turn
|
|
66
|
+
* `status` into a filesystem walk, and a run that silently examined the first
|
|
67
|
+
* few hundred would report "nothing missing" about a catalog it never read.
|
|
68
|
+
*/
|
|
69
|
+
export function observeMissingMarkers(repositoryRoot, subjects, limits = MARKER_OBSERVATION_LIMITS, exists = existsSync) {
|
|
70
|
+
const root = normalize(repositoryRoot);
|
|
71
|
+
const observations = [];
|
|
72
|
+
let checked = 0;
|
|
73
|
+
let stoppedEarly = false;
|
|
74
|
+
for (const subject of subjects) {
|
|
75
|
+
const missing = [];
|
|
76
|
+
for (const marker of [...(subject.surfaces ?? []), ...(subject.labels ?? [])]) {
|
|
77
|
+
if (!isPathShapedMarker(marker))
|
|
78
|
+
continue;
|
|
79
|
+
if (checked >= limits.maxChecked) {
|
|
80
|
+
stoppedEarly = true;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
checked += 1;
|
|
84
|
+
const resolved = resolveInside(root, marker);
|
|
85
|
+
if (resolved === undefined)
|
|
86
|
+
continue;
|
|
87
|
+
if (!exists(resolved))
|
|
88
|
+
missing.push(marker.trim());
|
|
89
|
+
}
|
|
90
|
+
if (missing.length > 0) {
|
|
91
|
+
observations.push({ promiseId: subject.promiseId, title: subject.title, missing });
|
|
92
|
+
}
|
|
93
|
+
if (stoppedEarly)
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
return { observations, checked, stoppedEarly };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The attention row a person reads, or nothing at all when every marker is
|
|
100
|
+
* still there.
|
|
101
|
+
*
|
|
102
|
+
* It names what to do and who does it: a marker is approved meaning, so the
|
|
103
|
+
* remedy is a revision somebody agrees to, never an edit this command makes.
|
|
104
|
+
*/
|
|
105
|
+
export function staleMarkerRow(result, limits = MARKER_OBSERVATION_LIMITS) {
|
|
106
|
+
const { observations } = result;
|
|
107
|
+
if (observations.length === 0)
|
|
108
|
+
return undefined;
|
|
109
|
+
const named = observations.slice(0, Math.max(0, limits.maxNamed));
|
|
110
|
+
const lines = named.map((observation) => ` ${observation.promiseId}: ${observation.missing.join(", ")} (${observation.title})`);
|
|
111
|
+
const rest = observations.length - named.length;
|
|
112
|
+
const head = observations.length === 1
|
|
113
|
+
? " Attention: 1 promise names a path that is not in this checkout, so it will not be found by the paths a change touches."
|
|
114
|
+
: ` Attention: ${observations.length} promises name a path that is not in this checkout, so they will not be found by the paths a change touches.`;
|
|
115
|
+
const tail = [
|
|
116
|
+
...(rest > 0 ? [` and ${rest} more.`] : []),
|
|
117
|
+
...(result.stoppedEarly
|
|
118
|
+
? [
|
|
119
|
+
` This run looked at ${result.checked} markers and stopped there, so there may be more.`,
|
|
120
|
+
]
|
|
121
|
+
: []),
|
|
122
|
+
" A marker is part of what somebody agreed to. Propose a revision and let its owner decide; nothing here changed it.",
|
|
123
|
+
];
|
|
124
|
+
return [head, ...lines, ...tail].join("\n");
|
|
125
|
+
}
|