@davidvornholt/standards 0.10.2 → 0.11.1

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 (45) hide show
  1. package/README.md +9 -2
  2. package/package.json +38 -1
  3. package/src/cli.ts +98 -87
  4. package/src/github-api.ts +2 -0
  5. package/src/github-commands.ts +2 -0
  6. package/src/github-label-identity.ts +9 -0
  7. package/src/github-labels.ts +123 -0
  8. package/src/github-live-drift.ts +29 -0
  9. package/src/github-paginate.ts +49 -0
  10. package/src/github-settings-merge.ts +167 -0
  11. package/src/github-settings-parse.ts +82 -7
  12. package/src/github-settings.ts +5 -123
  13. package/src/managed-files.ts +64 -0
  14. package/src/poller-approval.ts +80 -0
  15. package/src/poller-claim.ts +154 -0
  16. package/src/poller-codex.ts +93 -0
  17. package/src/poller-commands.ts +78 -0
  18. package/src/poller-config.ts +188 -0
  19. package/src/poller-fix-outcome.ts +36 -0
  20. package/src/poller-fix-output.ts +135 -0
  21. package/src/poller-fix-publication.ts +187 -0
  22. package/src/poller-fix-run.ts +175 -0
  23. package/src/poller-fix-validation.ts +64 -0
  24. package/src/poller-github-publication.ts +23 -0
  25. package/src/poller-github-pulls.ts +189 -0
  26. package/src/poller-github-write.ts +87 -0
  27. package/src/poller-github.ts +190 -0
  28. package/src/poller-job-shared.ts +132 -0
  29. package/src/poller-outcome.ts +136 -0
  30. package/src/poller-output-integrity.ts +107 -0
  31. package/src/poller-prompts.ts +70 -0
  32. package/src/poller-protected-paths.ts +51 -0
  33. package/src/poller-protocol.ts +137 -0
  34. package/src/poller-review-artifacts.ts +119 -0
  35. package/src/poller-review-execution.ts +108 -0
  36. package/src/poller-review-output.ts +189 -0
  37. package/src/poller-review-publication.ts +153 -0
  38. package/src/poller-review-run.ts +160 -0
  39. package/src/poller-review-state.ts +126 -0
  40. package/src/poller-review-validation.ts +66 -0
  41. package/src/poller-schedule.ts +121 -0
  42. package/src/poller-tick.ts +144 -0
  43. package/src/poller-trust.ts +96 -0
  44. package/src/poller-units.ts +82 -0
  45. package/src/poller-workspace.ts +199 -0
@@ -0,0 +1,154 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { hasLabel } from './github-label-identity';
3
+ import { type ApprovalBinding, readApprovalBinding } from './poller-approval';
4
+ import {
5
+ collaboratorRole,
6
+ getIssue,
7
+ type IssueComment,
8
+ lastLabelEvent,
9
+ listIssueComments,
10
+ } from './poller-github';
11
+ import { createComment } from './poller-github-write';
12
+ import { CLAIM_MARKER, isTrustedRole } from './poller-protocol';
13
+
14
+ export type ClaimBinding = {
15
+ readonly approval: ApprovalBinding;
16
+ readonly claimLabel: string;
17
+ readonly claimEpoch: string;
18
+ readonly markerId: number;
19
+ };
20
+
21
+ type ClaimMarker = Omit<ClaimBinding, 'markerId'> & {
22
+ readonly nonce: string;
23
+ };
24
+
25
+ type ClaimContext = {
26
+ readonly token: string | null;
27
+ readonly repo: string;
28
+ readonly issueNumber: number;
29
+ };
30
+
31
+ const markerBody = (marker: ClaimMarker): string =>
32
+ `${CLAIM_MARKER}\n${JSON.stringify(marker)}`;
33
+
34
+ const parseMarker = (comment: IssueComment): ClaimMarker | null => {
35
+ if (!comment.body.startsWith(`${CLAIM_MARKER}\n`)) {
36
+ return null;
37
+ }
38
+ try {
39
+ const raw = JSON.parse(comment.body.slice(CLAIM_MARKER.length + 1)) as {
40
+ readonly approval?: ApprovalBinding;
41
+ readonly claimLabel?: unknown;
42
+ readonly claimEpoch?: unknown;
43
+ readonly nonce?: unknown;
44
+ };
45
+ if (
46
+ typeof raw.claimLabel !== 'string' ||
47
+ typeof raw.claimEpoch !== 'string' ||
48
+ typeof raw.nonce !== 'string' ||
49
+ raw.approval === undefined ||
50
+ typeof raw.approval.id !== 'string'
51
+ ) {
52
+ return null;
53
+ }
54
+ return {
55
+ approval: raw.approval,
56
+ claimLabel: raw.claimLabel,
57
+ claimEpoch: raw.claimEpoch,
58
+ nonce: raw.nonce,
59
+ };
60
+ } catch {
61
+ return null;
62
+ }
63
+ };
64
+
65
+ const winningMarkerId = async (
66
+ context: ClaimContext,
67
+ binding: Omit<ClaimBinding, 'markerId'>,
68
+ ): Promise<number | null> => {
69
+ const comments = await listIssueComments(
70
+ context.token,
71
+ context.repo,
72
+ context.issueNumber,
73
+ );
74
+ let winner: number | null = null;
75
+ for (const comment of comments) {
76
+ const marker = parseMarker(comment);
77
+ const differs =
78
+ marker?.approval.id !== binding.approval.id ||
79
+ marker.claimLabel !== binding.claimLabel ||
80
+ marker.claimEpoch !== binding.claimEpoch;
81
+ if (!differs) {
82
+ // biome-ignore lint/performance/noAwaitInLoops: claim authors must be checked against their current role; the usually-one-item list is deliberately fail-closed.
83
+ const role = await collaboratorRole(
84
+ context.token,
85
+ context.repo,
86
+ comment.authorLogin,
87
+ );
88
+ if (isTrustedRole(role) && (winner === null || comment.id < winner)) {
89
+ winner = comment.id;
90
+ }
91
+ }
92
+ }
93
+ return winner;
94
+ };
95
+
96
+ export const acquireClaim = async (
97
+ context: ClaimContext,
98
+ approval: ApprovalBinding,
99
+ claimLabel: string,
100
+ ): Promise<ClaimBinding | null> => {
101
+ const event = await lastLabelEvent(
102
+ context.token,
103
+ context.repo,
104
+ context.issueNumber,
105
+ claimLabel,
106
+ );
107
+ if (event === null) {
108
+ throw new Error(`no "${claimLabel}" claim event found`);
109
+ }
110
+ const provisional = {
111
+ approval,
112
+ claimLabel,
113
+ claimEpoch: String(event.id),
114
+ };
115
+ const nonce = randomUUID();
116
+ const markerId = await createComment(
117
+ context.token,
118
+ context.repo,
119
+ context.issueNumber,
120
+ markerBody({ ...provisional, nonce }),
121
+ );
122
+ const winner = await winningMarkerId(context, provisional);
123
+ return winner === markerId ? { ...provisional, markerId } : null;
124
+ };
125
+
126
+ export const validateClaim = async (
127
+ context: ClaimContext,
128
+ claim: ClaimBinding,
129
+ currentTarget: string,
130
+ ): Promise<string | null> => {
131
+ const current = await readApprovalBinding(
132
+ context,
133
+ claim.approval.label,
134
+ currentTarget,
135
+ );
136
+ if (typeof current === 'string') {
137
+ return current;
138
+ }
139
+ if (current.id !== claim.approval.id) {
140
+ return 'approval no longer matches the exact approved revision/head';
141
+ }
142
+ const issue = await getIssue(
143
+ context.token,
144
+ context.repo,
145
+ context.issueNumber,
146
+ );
147
+ if (!hasLabel(issue.labels, claim.claimLabel)) {
148
+ return `"${claim.claimLabel}" is no longer present`;
149
+ }
150
+ const winner = await winningMarkerId(context, claim);
151
+ return winner === claim.markerId
152
+ ? null
153
+ : 'claim ownership changed or could not be proven';
154
+ };
@@ -0,0 +1,93 @@
1
+ // Codex invocation for poller jobs. The agent runs headless inside the job
2
+ // worktree and hands results back through the outcome file — never stdout,
3
+ // which is unreliable once agent tools are active. The poller then verifies
4
+ // effects (commits, diffs, gates) instead of trusting the narration.
5
+
6
+ import { execFileSync } from 'node:child_process';
7
+ import { rmSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import process from 'node:process';
10
+ import { isRecord } from './github-settings-parse';
11
+ import type { PollerConfig } from './poller-config';
12
+ import { OUTCOME_DIR } from './poller-protocol';
13
+
14
+ const MS_PER_SECOND = 1000;
15
+ const SECONDS_PER_MINUTE = 60;
16
+ const MS_PER_MINUTE = SECONDS_PER_MINUTE * MS_PER_SECOND;
17
+ const STDERR_SNIPPET_LIMIT = 2000;
18
+
19
+ export type CodexRunResult = {
20
+ readonly succeeded: boolean;
21
+ readonly failure: string | null;
22
+ };
23
+
24
+ type CodexExecutor = (
25
+ file: string,
26
+ args: ReadonlyArray<string>,
27
+ options: {
28
+ readonly encoding: 'utf8';
29
+ readonly timeout: number;
30
+ readonly stdio: readonly ['ignore', 'ignore', 'pipe'];
31
+ readonly env: Record<string, string | undefined>;
32
+ },
33
+ ) => unknown;
34
+
35
+ const defaultExecutor: CodexExecutor = execFileSync;
36
+
37
+ // Remove the poller's direct GitHub token variables so an approved Codex run
38
+ // cannot trivially bypass the protected-path and trust checks with API writes.
39
+ // The shared service identity is not credential-isolated: auth state and other
40
+ // ambient credentials in HOME remain readable as an explicitly accepted risk.
41
+ const agentEnv = (): Record<string, string | undefined> => {
42
+ const env = { ...process.env };
43
+ env.GH_TOKEN = undefined;
44
+ env.GITHUB_TOKEN = undefined;
45
+ env.STANDARDS_POLLER_GIT_TOKEN = undefined;
46
+ return env;
47
+ };
48
+
49
+ export const runCodex = (
50
+ workDir: string,
51
+ prompt: string,
52
+ config: PollerConfig,
53
+ execute: CodexExecutor = defaultExecutor,
54
+ ): CodexRunResult => {
55
+ rmSync(join(workDir, OUTCOME_DIR), { recursive: true, force: true });
56
+ try {
57
+ execute(
58
+ 'codex',
59
+ [
60
+ 'exec',
61
+ '--cd',
62
+ workDir,
63
+ '--sandbox',
64
+ 'workspace-write',
65
+ '-c',
66
+ 'sandbox_workspace_write.network_access=true',
67
+ '-m',
68
+ config.model,
69
+ '-c',
70
+ `model_reasoning_effort=${JSON.stringify(config.reasoningEffort)}`,
71
+ ...config.extraCodexArgs,
72
+ prompt,
73
+ ],
74
+ {
75
+ encoding: 'utf8',
76
+ timeout: config.runTimeoutMinutes * MS_PER_MINUTE,
77
+ stdio: ['ignore', 'ignore', 'pipe'],
78
+ env: agentEnv(),
79
+ },
80
+ );
81
+ return { succeeded: true, failure: null };
82
+ } catch (error) {
83
+ const stderr =
84
+ isRecord(error) && typeof error.stderr === 'string'
85
+ ? error.stderr.slice(-STDERR_SNIPPET_LIMIT)
86
+ : '';
87
+ const message = error instanceof Error ? error.message : String(error);
88
+ return {
89
+ succeeded: false,
90
+ failure: `codex exec failed: ${message}\n${stderr}`,
91
+ };
92
+ }
93
+ };
@@ -0,0 +1,78 @@
1
+ // CLI wiring for the poller command family. A tick is a gate-style command:
2
+ // it reports what it did and fails loudly when any repository could not be
3
+ // processed, so a red systemd unit is the durable signal that jobs stalled.
4
+
5
+ import { existsSync } from 'node:fs';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { dirname, resolve } from 'node:path';
8
+ import { resolveToken } from './github-api';
9
+ import { type PollerConfig, parsePollerConfig } from './poller-config';
10
+ import { runPollerTick } from './poller-tick';
11
+ import { runPollerPrintUnits } from './poller-units';
12
+
13
+ export type PollerCommandOptions = {
14
+ readonly configPath: string | undefined;
15
+ readonly printUnits: boolean;
16
+ };
17
+
18
+ const loadConfig = async (configPath: string): Promise<PollerConfig> => {
19
+ const path = resolve(configPath);
20
+ if (!existsSync(path)) {
21
+ throw new Error(`poller config not found: ${path}`);
22
+ }
23
+ let parsed: unknown;
24
+ try {
25
+ parsed = JSON.parse(await readFile(path, 'utf8'));
26
+ } catch (error) {
27
+ throw new Error(`poller config must contain valid JSON: ${path}`, {
28
+ cause: error,
29
+ });
30
+ }
31
+ const { config, problems } = parsePollerConfig(parsed, dirname(path));
32
+ if (config === null) {
33
+ throw new Error(
34
+ [
35
+ `invalid poller config ${path}:`,
36
+ ...problems.map((p) => ` - ${p}`),
37
+ ].join('\n'),
38
+ );
39
+ }
40
+ return config;
41
+ };
42
+
43
+ export const runPollerCommand = async (
44
+ options: PollerCommandOptions,
45
+ ): Promise<boolean> => {
46
+ if (options.configPath === undefined) {
47
+ console.error('standards poller: --config <path> is required');
48
+ return false;
49
+ }
50
+ // Every branch loads the config first: the units derive their tick budget
51
+ // from it, and a broken file must fail now, not on the first timer firing
52
+ // at 3am.
53
+ const config = await loadConfig(options.configPath);
54
+ if (options.printUnits) {
55
+ runPollerPrintUnits(options.configPath, config);
56
+ return true;
57
+ }
58
+ const token = resolveToken();
59
+ if (token === null) {
60
+ console.error(
61
+ 'standards poller: no GitHub token (GH_TOKEN, GITHUB_TOKEN, or gh auth); the poller cannot label, push, or open PRs anonymously',
62
+ );
63
+ return false;
64
+ }
65
+ const { lines, problems } = await runPollerTick(config, token, Date.now());
66
+ for (const line of lines) {
67
+ console.log(` ${line}`);
68
+ }
69
+ if (problems.length > 0) {
70
+ console.error(`standards poller: ${problems.length} problem(s):`);
71
+ console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
72
+ return false;
73
+ }
74
+ console.log(
75
+ `standards poller: tick complete (${lines.length} event(s) across ${config.repos.length} repo(s))`,
76
+ );
77
+ return true;
78
+ };
@@ -0,0 +1,188 @@
1
+ // Host-level poller configuration. The poller is host infrastructure, not
2
+ // repository state: one config file on the polling host lists every watched
3
+ // repository, and all workflow state lives in GitHub labels and comments so
4
+ // ticks are stateless and safe to re-run.
5
+
6
+ import { homedir } from 'node:os';
7
+ import { isAbsolute, join, resolve } from 'node:path';
8
+ import { isNonEmptyString, isRecord } from './github-settings-parse';
9
+
10
+ const REPO_PATTERN =
11
+ /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/u;
12
+
13
+ const CONFIG_KEYS: ReadonlySet<string> = new Set([
14
+ 'repos',
15
+ 'model',
16
+ 'reasoningEffort',
17
+ // biome-ignore lint/security/noSecrets: a config key name, not a credential.
18
+ 'maxJobsPerTick',
19
+ 'staleClaimHours',
20
+ 'runTimeoutMinutes',
21
+ 'cacheDir',
22
+ 'extraCodexArgs',
23
+ ]);
24
+
25
+ const DEFAULT_MAX_JOBS_PER_TICK = 1;
26
+ const DEFAULT_STALE_CLAIM_HOURS = 6;
27
+ // Thorough review runs legitimately take hours; the timeout exists to catch
28
+ // wedged agents, not to bound honest work.
29
+ const DEFAULT_RUN_TIMEOUT_MINUTES = 240;
30
+ const MINUTES_PER_HOUR = 60;
31
+
32
+ export type PollerConfig = {
33
+ readonly repos: ReadonlyArray<string>;
34
+ readonly model: string;
35
+ readonly reasoningEffort: string;
36
+ readonly maxJobsPerTick: number;
37
+ readonly staleClaimHours: number;
38
+ readonly runTimeoutMinutes: number;
39
+ readonly cacheDir: string;
40
+ readonly extraCodexArgs: ReadonlyArray<string>;
41
+ };
42
+
43
+ export type PollerConfigResult = {
44
+ readonly config: PollerConfig | null;
45
+ readonly problems: ReadonlyArray<string>;
46
+ };
47
+
48
+ const expandHome = (path: string): string =>
49
+ path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(1)) : path;
50
+
51
+ const parseRepos = (
52
+ raw: unknown,
53
+ problems: Array<string>,
54
+ ): ReadonlyArray<string> => {
55
+ if (!(Array.isArray(raw) && raw.every((repo) => typeof repo === 'string'))) {
56
+ problems.push('poller config "repos" must be a string array');
57
+ return [];
58
+ }
59
+ if (raw.length === 0) {
60
+ problems.push('poller config "repos" must list at least one repository');
61
+ }
62
+ for (const repo of raw) {
63
+ if (!REPO_PATTERN.test(repo)) {
64
+ problems.push(
65
+ `poller config "repos" entries must be "owner/repo": ${repo}`,
66
+ );
67
+ }
68
+ }
69
+ if (new Set(raw).size !== raw.length) {
70
+ problems.push('poller config "repos" entries must be unique');
71
+ }
72
+ return raw;
73
+ };
74
+
75
+ const parsePositiveInteger = (
76
+ raw: unknown,
77
+ field: string,
78
+ fallback: number,
79
+ problems: Array<string>,
80
+ ): number => {
81
+ if (raw === undefined) {
82
+ return fallback;
83
+ }
84
+ if (!(typeof raw === 'number' && Number.isInteger(raw) && raw > 0)) {
85
+ problems.push(`poller config "${field}" must be a positive integer`);
86
+ return fallback;
87
+ }
88
+ return raw;
89
+ };
90
+
91
+ const parseRequiredString = (
92
+ raw: unknown,
93
+ field: string,
94
+ problems: Array<string>,
95
+ ): string => {
96
+ if (!isNonEmptyString(raw)) {
97
+ problems.push(
98
+ `poller config "${field}" must be a non-empty string; the model choice is deliberate and has no default`,
99
+ );
100
+ return '';
101
+ }
102
+ return raw;
103
+ };
104
+
105
+ const parseExtraCodexArgs = (
106
+ raw: unknown,
107
+ problems: Array<string>,
108
+ ): ReadonlyArray<string> => {
109
+ if (raw === undefined) {
110
+ return [];
111
+ }
112
+ if (!(Array.isArray(raw) && raw.every((arg) => typeof arg === 'string'))) {
113
+ problems.push('poller config "extraCodexArgs" must be a string array');
114
+ return [];
115
+ }
116
+ return raw;
117
+ };
118
+
119
+ const parseCacheDir = (
120
+ raw: unknown,
121
+ configDir: string,
122
+ problems: Array<string>,
123
+ ): string => {
124
+ if (raw === undefined) {
125
+ return join(homedir(), '.cache', 'standards-poller');
126
+ }
127
+ if (!isNonEmptyString(raw)) {
128
+ problems.push('poller config "cacheDir" must be a non-empty string');
129
+ return join(homedir(), '.cache', 'standards-poller');
130
+ }
131
+ const expanded = expandHome(raw);
132
+ return isAbsolute(expanded) ? expanded : resolve(configDir, expanded);
133
+ };
134
+
135
+ // Reject unknown keys so a typo fails loudly instead of silently using a
136
+ // default, mirroring the sync policy and settings parsers.
137
+ export const parsePollerConfig = (
138
+ raw: unknown,
139
+ configDir: string,
140
+ ): PollerConfigResult => {
141
+ if (!isRecord(raw)) {
142
+ return { config: null, problems: ['poller config must be a JSON object'] };
143
+ }
144
+ const problems: Array<string> = [];
145
+ for (const key of Object.keys(raw)) {
146
+ if (!CONFIG_KEYS.has(key)) {
147
+ problems.push(`poller config has unknown key "${key}"`);
148
+ }
149
+ }
150
+ const config: PollerConfig = {
151
+ repos: parseRepos(raw.repos, problems),
152
+ model: parseRequiredString(raw.model, 'model', problems),
153
+ reasoningEffort: parseRequiredString(
154
+ raw.reasoningEffort,
155
+ 'reasoningEffort',
156
+ problems,
157
+ ),
158
+ maxJobsPerTick: parsePositiveInteger(
159
+ raw.maxJobsPerTick,
160
+ // biome-ignore lint/security/noSecrets: a config key name, not a credential.
161
+ 'maxJobsPerTick',
162
+ DEFAULT_MAX_JOBS_PER_TICK,
163
+ problems,
164
+ ),
165
+ staleClaimHours: parsePositiveInteger(
166
+ raw.staleClaimHours,
167
+ 'staleClaimHours',
168
+ DEFAULT_STALE_CLAIM_HOURS,
169
+ problems,
170
+ ),
171
+ runTimeoutMinutes: parsePositiveInteger(
172
+ raw.runTimeoutMinutes,
173
+ 'runTimeoutMinutes',
174
+ DEFAULT_RUN_TIMEOUT_MINUTES,
175
+ problems,
176
+ ),
177
+ cacheDir: parseCacheDir(raw.cacheDir, configDir, problems),
178
+ extraCodexArgs: parseExtraCodexArgs(raw.extraCodexArgs, problems),
179
+ };
180
+ if (config.staleClaimHours * MINUTES_PER_HOUR <= config.runTimeoutMinutes) {
181
+ problems.push(
182
+ 'poller config "staleClaimHours" must exceed "runTimeoutMinutes": a shorter stale sweep would release the claim of a job that is still running',
183
+ );
184
+ }
185
+ return problems.length > 0
186
+ ? { config: null, problems }
187
+ : { config, problems: [] };
188
+ };
@@ -0,0 +1,36 @@
1
+ import type { FixPublication } from './poller-fix-publication';
2
+ import { createComment } from './poller-github-write';
3
+ import {
4
+ askQuestion,
5
+ failJob,
6
+ type JobLabels,
7
+ releaseLabels,
8
+ } from './poller-job-shared';
9
+ import type { FixOutcome } from './poller-protocol';
10
+
11
+ export const handleNonFixedOutcome = async (
12
+ job: FixPublication,
13
+ labels: JobLabels,
14
+ outcome: FixOutcome,
15
+ ): Promise<string | null> => {
16
+ const { deps, issue } = job;
17
+ if (outcome.status === 'question') {
18
+ await askQuestion(deps, labels, issue.number, outcome.question ?? '');
19
+ return `#${issue.number}: asked a question`;
20
+ }
21
+ if (outcome.status === 'stale') {
22
+ await createComment(
23
+ deps.token,
24
+ deps.repo,
25
+ issue.number,
26
+ `The finding no longer reproduces on ${job.defaultBranch}: ${outcome.summary}\nClosing is left to a maintainer.`,
27
+ );
28
+ await releaseLabels(deps, labels, issue.number);
29
+ return `#${issue.number}: stale, needs human close`;
30
+ }
31
+ if (outcome.status === 'cannot-fix') {
32
+ await failJob(deps, labels, issue.number, outcome.summary);
33
+ return `#${issue.number}: cannot fix`;
34
+ }
35
+ return null;
36
+ };
@@ -0,0 +1,135 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { isRecord } from './github-settings-parse';
3
+ import {
4
+ assertCleanOutputWorktree,
5
+ commitCountBetween,
6
+ isAncestor,
7
+ isGitObjectId,
8
+ singleParentOf,
9
+ } from './poller-output-integrity';
10
+ import { FIX_OUTPUT_MARKER } from './poller-protocol';
11
+ import { runGit } from './poller-workspace';
12
+
13
+ export type SealedFixOutput = {
14
+ readonly repo: string;
15
+ readonly issueNumber: number;
16
+ readonly approvalId: string;
17
+ readonly title: string;
18
+ readonly body: string;
19
+ readonly baseSha: string;
20
+ readonly commits: number;
21
+ readonly generatedHead: string;
22
+ readonly sealedHead: string;
23
+ };
24
+
25
+ const fixOutputMessage = (
26
+ output: Omit<SealedFixOutput, 'sealedHead'>,
27
+ ): string =>
28
+ `${FIX_OUTPUT_MARKER}\n${Buffer.from(JSON.stringify(output)).toString('base64url')}`;
29
+
30
+ const parseFixOutputMessage = (
31
+ message: string,
32
+ sealedHead: string,
33
+ ): SealedFixOutput | null => {
34
+ const [marker, encoded] = message.trim().split('\n');
35
+ if (marker !== FIX_OUTPUT_MARKER || encoded === undefined) {
36
+ return null;
37
+ }
38
+ try {
39
+ const raw = JSON.parse(
40
+ Buffer.from(encoded, 'base64url').toString('utf8'),
41
+ ) as unknown;
42
+ if (
43
+ !isRecord(raw) ||
44
+ typeof raw.repo !== 'string' ||
45
+ typeof raw.issueNumber !== 'number' ||
46
+ typeof raw.approvalId !== 'string' ||
47
+ typeof raw.title !== 'string' ||
48
+ typeof raw.body !== 'string' ||
49
+ typeof raw.baseSha !== 'string' ||
50
+ typeof raw.commits !== 'number' ||
51
+ !Number.isInteger(raw.commits) ||
52
+ raw.commits < 1 ||
53
+ typeof raw.generatedHead !== 'string' ||
54
+ !isGitObjectId(raw.baseSha) ||
55
+ !isGitObjectId(raw.generatedHead)
56
+ ) {
57
+ return null;
58
+ }
59
+ return { ...raw, sealedHead } as SealedFixOutput;
60
+ } catch {
61
+ return null;
62
+ }
63
+ };
64
+
65
+ export const sealFixOutput = (
66
+ workDir: string,
67
+ output: Omit<SealedFixOutput, 'generatedHead' | 'sealedHead'>,
68
+ ): SealedFixOutput => {
69
+ assertCleanOutputWorktree(workDir);
70
+ const generatedHead = runGit(
71
+ ['-C', workDir, 'rev-parse', 'HEAD'],
72
+ null,
73
+ ).trim();
74
+ runGit(
75
+ [
76
+ '-C',
77
+ workDir,
78
+ '-c',
79
+ 'user.name=standards-poller',
80
+ '-c',
81
+ 'user.email=standards-poller@users.noreply.github.com',
82
+ '-c',
83
+ 'commit.gpgSign=false',
84
+ 'commit',
85
+ '--allow-empty',
86
+ '--only',
87
+ '-m',
88
+ fixOutputMessage({ ...output, generatedHead }),
89
+ ],
90
+ null,
91
+ );
92
+ const sealedHead = runGit(['-C', workDir, 'rev-parse', 'HEAD'], null).trim();
93
+ return { ...output, generatedHead, sealedHead };
94
+ };
95
+
96
+ export const readSealedFixOutput = (
97
+ cloneDir: string,
98
+ branch: string,
99
+ ): SealedFixOutput | null => {
100
+ try {
101
+ const sealedHead = runGit(
102
+ ['-C', cloneDir, 'rev-parse', `refs/heads/${branch}`],
103
+ null,
104
+ ).trim();
105
+ const changed = runGit(
106
+ [
107
+ '-C',
108
+ cloneDir,
109
+ 'diff-tree',
110
+ '--no-commit-id',
111
+ '--name-only',
112
+ '-r',
113
+ sealedHead,
114
+ ],
115
+ null,
116
+ ).trim();
117
+ const parsed = parseFixOutputMessage(
118
+ runGit(['-C', cloneDir, 'log', '-1', '--format=%B', sealedHead], null),
119
+ sealedHead,
120
+ );
121
+ if (
122
+ changed.length > 0 ||
123
+ parsed === null ||
124
+ singleParentOf(cloneDir, sealedHead) !== parsed.generatedHead ||
125
+ !isAncestor(cloneDir, parsed.baseSha, parsed.generatedHead) ||
126
+ commitCountBetween(cloneDir, parsed.baseSha, parsed.generatedHead) !==
127
+ parsed.commits
128
+ ) {
129
+ return null;
130
+ }
131
+ return parsed;
132
+ } catch {
133
+ return null;
134
+ }
135
+ };