@davidvornholt/standards 0.10.2 → 0.11.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/README.md +6 -1
- package/package.json +37 -1
- package/src/cli.ts +68 -11
- package/src/github-commands.ts +2 -0
- package/src/github-label-identity.ts +9 -0
- package/src/github-labels.ts +123 -0
- package/src/github-live-drift.ts +8 -0
- package/src/github-paginate.ts +37 -0
- package/src/github-settings-merge.ts +167 -0
- package/src/github-settings-parse.ts +82 -7
- package/src/github-settings.ts +5 -123
- package/src/poller-approval.ts +80 -0
- package/src/poller-claim.ts +154 -0
- package/src/poller-codex.ts +93 -0
- package/src/poller-commands.ts +78 -0
- package/src/poller-config.ts +188 -0
- package/src/poller-fix-outcome.ts +36 -0
- package/src/poller-fix-output.ts +135 -0
- package/src/poller-fix-publication.ts +187 -0
- package/src/poller-fix-run.ts +175 -0
- package/src/poller-fix-validation.ts +64 -0
- package/src/poller-github-publication.ts +23 -0
- package/src/poller-github-pulls.ts +189 -0
- package/src/poller-github-write.ts +87 -0
- package/src/poller-github.ts +190 -0
- package/src/poller-job-shared.ts +132 -0
- package/src/poller-outcome.ts +136 -0
- package/src/poller-output-integrity.ts +107 -0
- package/src/poller-prompts.ts +70 -0
- package/src/poller-protected-paths.ts +51 -0
- package/src/poller-protocol.ts +137 -0
- package/src/poller-review-artifacts.ts +119 -0
- package/src/poller-review-execution.ts +108 -0
- package/src/poller-review-output.ts +189 -0
- package/src/poller-review-publication.ts +153 -0
- package/src/poller-review-run.ts +160 -0
- package/src/poller-review-state.ts +126 -0
- package/src/poller-review-validation.ts +66 -0
- package/src/poller-schedule.ts +121 -0
- package/src/poller-tick.ts +144 -0
- package/src/poller-trust.ts +96 -0
- package/src/poller-units.ts +82 -0
- package/src/poller-workspace.ts +199 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Trust decisions for poller jobs. Two questions, both answered from GitHub
|
|
2
|
+
// state and repository roles, never from configuration: did a maintainer
|
|
3
|
+
// approve this job, and which comments count as answers to the agent's
|
|
4
|
+
// questions? Everything else on a public repository is untrusted text.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
collaboratorRole,
|
|
8
|
+
type IssueComment,
|
|
9
|
+
listIssueComments,
|
|
10
|
+
} from './poller-github';
|
|
11
|
+
import { isTrustedRole, QUESTION_MARKER } from './poller-protocol';
|
|
12
|
+
|
|
13
|
+
export type RoleCache = Map<string, string>;
|
|
14
|
+
|
|
15
|
+
export type TrustContext = {
|
|
16
|
+
readonly token: string | null;
|
|
17
|
+
readonly repo: string;
|
|
18
|
+
readonly issueNumber: number;
|
|
19
|
+
readonly roleCache: RoleCache;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const cachedRole = async (
|
|
23
|
+
context: TrustContext,
|
|
24
|
+
username: string,
|
|
25
|
+
): Promise<string> => {
|
|
26
|
+
const key = `${context.repo}:${username}`;
|
|
27
|
+
const cached = context.roleCache.get(key);
|
|
28
|
+
if (cached !== undefined) {
|
|
29
|
+
return cached;
|
|
30
|
+
}
|
|
31
|
+
const role = await collaboratorRole(context.token, context.repo, username);
|
|
32
|
+
context.roleCache.set(key, role);
|
|
33
|
+
return role;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type AnswerState = {
|
|
37
|
+
readonly waiting: boolean;
|
|
38
|
+
readonly answers: ReadonlyArray<string>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const hasQuestionMarker = (comment: IssueComment): boolean =>
|
|
42
|
+
comment.body.includes(QUESTION_MARKER);
|
|
43
|
+
|
|
44
|
+
// The marker is public, so a marker-bearing comment counts as the poller's
|
|
45
|
+
// question only when its author is trusted (the poller posts with a
|
|
46
|
+
// maintainer token). Otherwise anyone could re-post the marker after a real
|
|
47
|
+
// answer and park the job in "waiting" forever.
|
|
48
|
+
const lastTrustedQuestionIndex = async (
|
|
49
|
+
context: TrustContext,
|
|
50
|
+
comments: ReadonlyArray<IssueComment>,
|
|
51
|
+
): Promise<number> => {
|
|
52
|
+
for (let index = comments.length - 1; index >= 0; index -= 1) {
|
|
53
|
+
const comment = comments[index];
|
|
54
|
+
if (comment !== undefined && hasQuestionMarker(comment)) {
|
|
55
|
+
// biome-ignore lint/performance/noAwaitInLoops: role lookups go through the shared per-tick cache; parallel lookups would race it and duplicate API reads.
|
|
56
|
+
const role = await cachedRole(context, comment.authorLogin);
|
|
57
|
+
if (isTrustedRole(role)) {
|
|
58
|
+
return index;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return -1;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Answers are trusted-role comments posted after the poller's latest
|
|
66
|
+
// question. With a question outstanding and no such comment yet, the job
|
|
67
|
+
// waits; comments from anyone else are ignored entirely and never reach a
|
|
68
|
+
// prompt.
|
|
69
|
+
export const answerState = async (
|
|
70
|
+
context: TrustContext,
|
|
71
|
+
): Promise<AnswerState> => {
|
|
72
|
+
const comments = await listIssueComments(
|
|
73
|
+
context.token,
|
|
74
|
+
context.repo,
|
|
75
|
+
context.issueNumber,
|
|
76
|
+
);
|
|
77
|
+
const lastQuestionIndex = await lastTrustedQuestionIndex(context, comments);
|
|
78
|
+
if (lastQuestionIndex === -1) {
|
|
79
|
+
return { waiting: false, answers: [] };
|
|
80
|
+
}
|
|
81
|
+
const answers: Array<string> = [];
|
|
82
|
+
const candidates = comments
|
|
83
|
+
.slice(lastQuestionIndex + 1)
|
|
84
|
+
.filter(
|
|
85
|
+
(comment) =>
|
|
86
|
+
!hasQuestionMarker(comment) && comment.authorLogin.length > 0,
|
|
87
|
+
);
|
|
88
|
+
for (const comment of candidates) {
|
|
89
|
+
// biome-ignore lint/performance/noAwaitInLoops: role lookups go through the shared per-tick cache; parallel lookups would race it and duplicate API reads.
|
|
90
|
+
const role = await cachedRole(context, comment.authorLogin);
|
|
91
|
+
if (isTrustedRole(role)) {
|
|
92
|
+
answers.push(comment.body);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { waiting: answers.length === 0, answers };
|
|
96
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Declarative systemd unit rendering for the host infrastructure repository.
|
|
2
|
+
// This command only prints content: host mutation belongs to trusted
|
|
3
|
+
// main-branch automation in the repository that owns the polling host.
|
|
4
|
+
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import type { PollerConfig } from './poller-config';
|
|
9
|
+
|
|
10
|
+
const SERVICE_NAME = 'standards-poller';
|
|
11
|
+
const TICK_INTERVAL_MINUTES = 10;
|
|
12
|
+
// Non-agent tick work: stale sweeps, clone/fetch, GitHub reads and writes.
|
|
13
|
+
const TICK_OVERHEAD_MINUTES = 30;
|
|
14
|
+
const MAX_ASCII_CONTROL_CODE = 31;
|
|
15
|
+
const DELETE_CONTROL_CODE = 127;
|
|
16
|
+
|
|
17
|
+
const cliEntryPath = (): string =>
|
|
18
|
+
fileURLToPath(new URL('cli.ts', import.meta.url));
|
|
19
|
+
|
|
20
|
+
// systemd parses ExecStart itself rather than through a shell. Quote every
|
|
21
|
+
// path as one token, escape its two expansion sigils, and reject control
|
|
22
|
+
// characters instead of emitting a unit whose meaning differs from the path.
|
|
23
|
+
export const quoteSystemdExecArg = (value: string): string => {
|
|
24
|
+
const hasControlCharacter = [...value].some((character) => {
|
|
25
|
+
const code = character.charCodeAt(0);
|
|
26
|
+
return code <= MAX_ASCII_CONTROL_CODE || code === DELETE_CONTROL_CODE;
|
|
27
|
+
});
|
|
28
|
+
if (hasControlCharacter) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
'systemd ExecStart arguments cannot contain control characters',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return `"${value
|
|
34
|
+
.replaceAll('\\', '\\\\')
|
|
35
|
+
.replaceAll('"', '\\"')
|
|
36
|
+
.replaceAll('$', '$$$$')
|
|
37
|
+
.replaceAll('%', '%%')}"`;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// The tick budget follows the config — every job may use the full agent
|
|
41
|
+
// timeout — so raising "runTimeoutMinutes" or "maxJobsPerTick" cannot
|
|
42
|
+
// silently outgrow the unit. Changing either means re-rendering the units.
|
|
43
|
+
const tickBudgetMinutes = (config: PollerConfig): number =>
|
|
44
|
+
config.maxJobsPerTick * config.runTimeoutMinutes + TICK_OVERHEAD_MINUTES;
|
|
45
|
+
|
|
46
|
+
export const renderUnits = (
|
|
47
|
+
configPath: string,
|
|
48
|
+
config: PollerConfig,
|
|
49
|
+
): { readonly service: string; readonly timer: string } => ({
|
|
50
|
+
service: `[Unit]
|
|
51
|
+
Description=Standards fix poller tick
|
|
52
|
+
After=network-online.target
|
|
53
|
+
Wants=network-online.target
|
|
54
|
+
|
|
55
|
+
[Service]
|
|
56
|
+
Type=oneshot
|
|
57
|
+
ExecStart=${quoteSystemdExecArg(process.execPath)} ${quoteSystemdExecArg(cliEntryPath())} poller --config ${quoteSystemdExecArg(configPath)}
|
|
58
|
+
TimeoutStartSec=${tickBudgetMinutes(config)}min
|
|
59
|
+
`,
|
|
60
|
+
timer: `[Unit]
|
|
61
|
+
Description=Run the standards fix poller on an interval
|
|
62
|
+
|
|
63
|
+
[Timer]
|
|
64
|
+
OnBootSec=2min
|
|
65
|
+
OnUnitActiveSec=${TICK_INTERVAL_MINUTES}min
|
|
66
|
+
Persistent=true
|
|
67
|
+
|
|
68
|
+
[Install]
|
|
69
|
+
WantedBy=timers.target
|
|
70
|
+
`,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export const runPollerPrintUnits = (
|
|
74
|
+
configPath: string,
|
|
75
|
+
config: PollerConfig,
|
|
76
|
+
): void => {
|
|
77
|
+
const units = renderUnits(resolve(configPath), config);
|
|
78
|
+
console.log(`# ${SERVICE_NAME}.service`);
|
|
79
|
+
console.log(units.service);
|
|
80
|
+
console.log(`# ${SERVICE_NAME}.timer`);
|
|
81
|
+
console.log(units.timer);
|
|
82
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Git workspace management for poller jobs: a bare clone per repository as
|
|
2
|
+
// the local cache, plus a throwaway worktree per job. The token never appears
|
|
3
|
+
// in argv or remote URLs — a credential helper reads it from the environment.
|
|
4
|
+
// Deliberately not `--mirror`: mirror remotes refuse refspec pushes, and the
|
|
5
|
+
// poller pushes exactly one branch per job.
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import process from 'node:process';
|
|
11
|
+
|
|
12
|
+
const TOKEN_ENV = 'STANDARDS_POLLER_GIT_TOKEN';
|
|
13
|
+
const CREDENTIAL_HELPER = `!f() { echo username=x-access-token; echo "password=$${TOKEN_ENV}"; }; f`;
|
|
14
|
+
const MS_PER_SECOND = 1000;
|
|
15
|
+
const GIT_TIMEOUT_SECONDS = 600;
|
|
16
|
+
const GIT_TIMEOUT_MS = GIT_TIMEOUT_SECONDS * MS_PER_SECOND;
|
|
17
|
+
const fetchRefspec = '+refs/heads/*:refs/heads/*';
|
|
18
|
+
|
|
19
|
+
export const githubRepoUrl = (repo: string): string =>
|
|
20
|
+
`https://github.com/${repo}.git`;
|
|
21
|
+
|
|
22
|
+
export type Workspace = {
|
|
23
|
+
readonly dir: string;
|
|
24
|
+
readonly baseSha: string;
|
|
25
|
+
readonly cleanup: () => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const runGit = (
|
|
29
|
+
args: ReadonlyArray<string>,
|
|
30
|
+
token: string | null,
|
|
31
|
+
): string =>
|
|
32
|
+
execFileSync('git', [...args], {
|
|
33
|
+
encoding: 'utf8',
|
|
34
|
+
timeout: GIT_TIMEOUT_MS,
|
|
35
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
36
|
+
env: {
|
|
37
|
+
...process.env,
|
|
38
|
+
...(token === null ? {} : { [TOKEN_ENV]: token }),
|
|
39
|
+
// biome-ignore lint/style/useNamingConvention: environment variable names are defined by git.
|
|
40
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const authedGit = (
|
|
45
|
+
cloneDir: string,
|
|
46
|
+
args: ReadonlyArray<string>,
|
|
47
|
+
token: string | null,
|
|
48
|
+
): string =>
|
|
49
|
+
runGit(
|
|
50
|
+
[
|
|
51
|
+
'-C',
|
|
52
|
+
cloneDir,
|
|
53
|
+
'-c',
|
|
54
|
+
'credential.helper=',
|
|
55
|
+
'-c',
|
|
56
|
+
`credential.helper=${CREDENTIAL_HELPER}`,
|
|
57
|
+
...args,
|
|
58
|
+
],
|
|
59
|
+
token,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Fetch (or first create) the bare cache clone for a repository and return
|
|
63
|
+
// its path. Bare clones carry no fetch refspec by default, so one is set
|
|
64
|
+
// explicitly; every branch ref stays local so worktrees can base on any ref.
|
|
65
|
+
export const ensureCacheClone = (
|
|
66
|
+
cacheDir: string,
|
|
67
|
+
repo: string,
|
|
68
|
+
token: string | null,
|
|
69
|
+
): string => {
|
|
70
|
+
const cloneDir = join(cacheDir, `${repo}.git`);
|
|
71
|
+
if (!existsSync(cloneDir)) {
|
|
72
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
73
|
+
runGit(
|
|
74
|
+
[
|
|
75
|
+
'-c',
|
|
76
|
+
'credential.helper=',
|
|
77
|
+
'-c',
|
|
78
|
+
`credential.helper=${CREDENTIAL_HELPER}`,
|
|
79
|
+
'clone',
|
|
80
|
+
'--bare',
|
|
81
|
+
githubRepoUrl(repo),
|
|
82
|
+
cloneDir,
|
|
83
|
+
],
|
|
84
|
+
token,
|
|
85
|
+
);
|
|
86
|
+
runGit(
|
|
87
|
+
['-C', cloneDir, 'config', 'remote.origin.fetch', fetchRefspec],
|
|
88
|
+
null,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
runGit(
|
|
92
|
+
['-C', cloneDir, 'remote', 'set-url', 'origin', githubRepoUrl(repo)],
|
|
93
|
+
null,
|
|
94
|
+
);
|
|
95
|
+
authedGit(
|
|
96
|
+
cloneDir,
|
|
97
|
+
['fetch', '--prune', githubRepoUrl(repo), fetchRefspec],
|
|
98
|
+
token,
|
|
99
|
+
);
|
|
100
|
+
return cloneDir;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// A leaked worktree from a killed run must not wedge its job forever: remove
|
|
104
|
+
// any stale registration for this path before adding the new one.
|
|
105
|
+
export const createWorktree = (
|
|
106
|
+
cloneDir: string,
|
|
107
|
+
startRef: string,
|
|
108
|
+
branch: string,
|
|
109
|
+
workDir: string,
|
|
110
|
+
): Workspace => {
|
|
111
|
+
try {
|
|
112
|
+
runGit(['-C', cloneDir, 'worktree', 'remove', '--force', workDir], null);
|
|
113
|
+
} catch {
|
|
114
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
115
|
+
runGit(['-C', cloneDir, 'worktree', 'prune'], null);
|
|
116
|
+
}
|
|
117
|
+
const baseSha = runGit(['-C', cloneDir, 'rev-parse', startRef], null).trim();
|
|
118
|
+
runGit(
|
|
119
|
+
['-C', cloneDir, 'worktree', 'add', '--detach', workDir, baseSha],
|
|
120
|
+
null,
|
|
121
|
+
);
|
|
122
|
+
runGit(['-C', workDir, 'checkout', '-B', branch, baseSha], null);
|
|
123
|
+
const cleanup = (): void => {
|
|
124
|
+
try {
|
|
125
|
+
runGit(['-C', cloneDir, 'worktree', 'remove', '--force', workDir], null);
|
|
126
|
+
} catch {
|
|
127
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
128
|
+
runGit(['-C', cloneDir, 'worktree', 'prune'], null);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
return { dir: workDir, baseSha, cleanup };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const mergeBase = (
|
|
135
|
+
cloneDir: string,
|
|
136
|
+
refA: string,
|
|
137
|
+
refB: string,
|
|
138
|
+
): string => runGit(['-C', cloneDir, 'merge-base', refA, refB], null).trim();
|
|
139
|
+
|
|
140
|
+
export const commitCount = (workDir: string, baseSha: string): number =>
|
|
141
|
+
Number.parseInt(
|
|
142
|
+
runGit(['-C', workDir, 'rev-list', '--count', `${baseSha}..HEAD`], null),
|
|
143
|
+
10,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
export const headSha = (workDir: string): string =>
|
|
147
|
+
runGit(['-C', workDir, 'rev-parse', 'HEAD'], null).trim();
|
|
148
|
+
|
|
149
|
+
export const localBranchExists = (
|
|
150
|
+
cloneDir: string,
|
|
151
|
+
branch: string,
|
|
152
|
+
): boolean => {
|
|
153
|
+
try {
|
|
154
|
+
runGit(
|
|
155
|
+
['-C', cloneDir, 'show-ref', '--verify', `refs/heads/${branch}`],
|
|
156
|
+
null,
|
|
157
|
+
);
|
|
158
|
+
return true;
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const changedPaths = (
|
|
165
|
+
workDir: string,
|
|
166
|
+
baseSha: string,
|
|
167
|
+
): ReadonlyArray<string> =>
|
|
168
|
+
runGit(['-C', workDir, 'diff', '--name-only', baseSha, 'HEAD'], null)
|
|
169
|
+
.split('\n')
|
|
170
|
+
.map((line) => line.trim())
|
|
171
|
+
.filter((line) => line.length > 0);
|
|
172
|
+
|
|
173
|
+
// Review branches are fast-forward-only. New poller-owned fix branches use an
|
|
174
|
+
// explicit "expected absent" lease, so a pre-existing ref is never overwritten.
|
|
175
|
+
export const pushBranch = (
|
|
176
|
+
workDir: string,
|
|
177
|
+
options: {
|
|
178
|
+
readonly repo: string;
|
|
179
|
+
readonly branch: string;
|
|
180
|
+
readonly token: string | null;
|
|
181
|
+
readonly expectedRemoteSha?: string;
|
|
182
|
+
readonly sourceRef?: string;
|
|
183
|
+
},
|
|
184
|
+
): void => {
|
|
185
|
+
authedGit(
|
|
186
|
+
workDir,
|
|
187
|
+
[
|
|
188
|
+
'push',
|
|
189
|
+
...(options.expectedRemoteSha === undefined
|
|
190
|
+
? []
|
|
191
|
+
: [
|
|
192
|
+
`--force-with-lease=refs/heads/${options.branch}:${options.expectedRemoteSha}`,
|
|
193
|
+
]),
|
|
194
|
+
githubRepoUrl(options.repo),
|
|
195
|
+
`${options.sourceRef ?? 'HEAD'}:refs/heads/${options.branch}`,
|
|
196
|
+
],
|
|
197
|
+
options.token,
|
|
198
|
+
);
|
|
199
|
+
};
|