@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.
Files changed (43) hide show
  1. package/README.md +6 -1
  2. package/package.json +37 -1
  3. package/src/cli.ts +68 -11
  4. package/src/github-commands.ts +2 -0
  5. package/src/github-label-identity.ts +9 -0
  6. package/src/github-labels.ts +123 -0
  7. package/src/github-live-drift.ts +8 -0
  8. package/src/github-paginate.ts +37 -0
  9. package/src/github-settings-merge.ts +167 -0
  10. package/src/github-settings-parse.ts +82 -7
  11. package/src/github-settings.ts +5 -123
  12. package/src/poller-approval.ts +80 -0
  13. package/src/poller-claim.ts +154 -0
  14. package/src/poller-codex.ts +93 -0
  15. package/src/poller-commands.ts +78 -0
  16. package/src/poller-config.ts +188 -0
  17. package/src/poller-fix-outcome.ts +36 -0
  18. package/src/poller-fix-output.ts +135 -0
  19. package/src/poller-fix-publication.ts +187 -0
  20. package/src/poller-fix-run.ts +175 -0
  21. package/src/poller-fix-validation.ts +64 -0
  22. package/src/poller-github-publication.ts +23 -0
  23. package/src/poller-github-pulls.ts +189 -0
  24. package/src/poller-github-write.ts +87 -0
  25. package/src/poller-github.ts +190 -0
  26. package/src/poller-job-shared.ts +132 -0
  27. package/src/poller-outcome.ts +136 -0
  28. package/src/poller-output-integrity.ts +107 -0
  29. package/src/poller-prompts.ts +70 -0
  30. package/src/poller-protected-paths.ts +51 -0
  31. package/src/poller-protocol.ts +137 -0
  32. package/src/poller-review-artifacts.ts +119 -0
  33. package/src/poller-review-execution.ts +108 -0
  34. package/src/poller-review-output.ts +189 -0
  35. package/src/poller-review-publication.ts +153 -0
  36. package/src/poller-review-run.ts +160 -0
  37. package/src/poller-review-state.ts +126 -0
  38. package/src/poller-review-validation.ts +66 -0
  39. package/src/poller-schedule.ts +121 -0
  40. package/src/poller-tick.ts +144 -0
  41. package/src/poller-trust.ts +96 -0
  42. package/src/poller-units.ts +82 -0
  43. 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
+ };