@alecsibilia/luca 14.0.0-alpha.5 → 14.0.0-alpha.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.6
4
+
5
+ No changes in this release.
6
+
3
7
  ## 14.0.0-alpha.5
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.5",
3
+ "version": "14.0.0-alpha.6",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -279,7 +279,10 @@ export const BOARD_VOCABULARY = {
279
279
  reason: z.string(),
280
280
  detail: z.string().catch(''),
281
281
  }),
282
- /** The whole run is stuck (#435): `run_budget`, it used up its budget. */
282
+ /**
283
+ * The whole run is stuck (#435): `run_budget`, it used up its budget,
284
+ * or `pull_request_failed`, its PR didn't open (#508).
285
+ */
283
286
  run_stuck: z.looseObject({
284
287
  reason: z.string(),
285
288
  detail: z.string().catch(''),
@@ -1,2 +1,2 @@
1
1
  /** The Luca version of this board, stamped when the package was packed. */
2
- export const LUCA_VERSION: string = "14.0.0-alpha.5"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.6"
@@ -408,6 +408,8 @@ const resolveNeedsYou = ({
408
408
  const RUN_STUCK_REASONS: Record<string, string> = {
409
409
  run_budget:
410
410
  'The run used up its run budget of tokens. `retry` adds one more full budget.',
411
+ pull_request_failed:
412
+ "The run's pull request didn't open. `retry` tries again, reusing a PR already open from the run branch.",
411
413
  }
412
414
 
413
415
  /** A run stuck reason code in words. */
@@ -190,6 +190,28 @@ export const prepareSection = ({ command }: { command: string }): string =>
190
190
  "If the engine's prepare command fails after your turn, its output comes back to you like a failed check.",
191
191
  ].join('\n\n')
192
192
 
193
+ /**
194
+ * The section a test-writer or implementer (a ticket's or the final
195
+ * review's fixers) gets in a repo with changesets: the engine writes the
196
+ * run's one changeset, so an agent's own is extra.
197
+ */
198
+ export const CHANGESETS_SECTION = [
199
+ '## The engine writes the changeset',
200
+ "Don't write changesets, even if the repo's rules ask for one: the engine writes the run's one changeset when it opens the PR. " +
201
+ 'A new file you add in `.changeset/` is removed before the commit. ' +
202
+ 'A finding that only asks for a changeset is answered by this: answer it "wont_fix" and say the engine adds it.',
203
+ ].join('\n\n')
204
+
205
+ /**
206
+ * The section the rules lens gets in a repo with changesets, so a rule like
207
+ * "every PR needs a changeset" raises no finding.
208
+ */
209
+ export const CHANGESETS_LENS_SECTION = [
210
+ '## The engine writes the changeset',
211
+ "The engine adds the run's one changeset when it opens the PR, so a rule that asks for a changeset is met. " +
212
+ 'Raise no finding that only asks for a changeset.',
213
+ ].join('\n\n')
214
+
193
215
  /** What each lens judges the whole run branch on, in one paragraph. */
194
216
  const LENS_TASK_TEXT =
195
217
  'You are one lens of the final review: a fresh, read-only reviewer of the WHOLE run branch, every ticket of the spec together. Write nothing.'
@@ -47,6 +47,8 @@ import {
47
47
  import { badTestDetail, retrySection, setupChangeDetail } from './stuck-text'
48
48
 
49
49
  import {
50
+ CHANGESETS_LENS_SECTION,
51
+ CHANGESETS_SECTION,
50
52
  prepareSection,
51
53
  rejoinSection,
52
54
  rolePrompt,
@@ -1303,34 +1305,47 @@ export const decideBuild = ({
1303
1305
  }): BuildAction[] => {
1304
1306
  const steps = buildSteps({ state, spec_number })
1305
1307
  const command = state.config?.prepare
1306
- return command === undefined
1307
- ? steps
1308
- : steps.map((step) => withPrepareSection({ step, command }))
1308
+ const prepared =
1309
+ command === undefined
1310
+ ? steps
1311
+ : steps.map((step) =>
1312
+ withWriterSection({
1313
+ step,
1314
+ section: prepareSection({ command }),
1315
+ })
1316
+ )
1317
+ return state.changesets ? prepared.map(withChangesetsSections) : prepared
1309
1318
  }
1310
1319
 
1311
1320
  /**
1312
1321
  * A fresh test-writer's or implementer's launch (a ticket's or the final
1313
- * review's fixers), with the repo's prepare command named in its prompt.
1314
- * Any other step as it is.
1322
+ * review's fixers), with `section` added to its prompt, such as the repo's
1323
+ * prepare command. Any other step as it is.
1315
1324
  */
1316
- const withPrepareSection = ({
1325
+ const withWriterSection = ({
1317
1326
  step,
1318
- command,
1327
+ section,
1319
1328
  }: {
1320
1329
  step: BuildAction
1321
- command: string
1330
+ section: string
1322
1331
  }): BuildAction => {
1323
1332
  const writesCode =
1324
1333
  (step.type === 'launch_agent' || step.type === 'launch_final_fixer') &&
1325
1334
  (step.role === 'test-writer' || step.role === 'implementer')
1326
1335
  return writesCode
1327
- ? {
1328
- ...step,
1329
- prompt: `${step.prompt}\n\n${prepareSection({ command })}`,
1330
- }
1336
+ ? { ...step, prompt: `${step.prompt}\n\n${section}` }
1331
1337
  : step
1332
1338
  }
1333
1339
 
1340
+ /**
1341
+ * In a repo with changesets, the writers are told not to write one and the
1342
+ * rules lens that the engine adds the run's one at PR time (#507).
1343
+ */
1344
+ const withChangesetsSections = (step: BuildAction): BuildAction =>
1345
+ step.type === 'launch_lens' && step.lens === 'rules'
1346
+ ? { ...step, prompt: `${step.prompt}\n\n${CHANGESETS_LENS_SECTION}` }
1347
+ : withWriterSection({ step, section: CHANGESETS_SECTION })
1348
+
1334
1349
  /** `decideBuild`'s steps, before the prepare command joins any prompt. */
1335
1350
  const buildSteps = ({
1336
1351
  state,
@@ -1,5 +1,9 @@
1
1
  import { pendingReplies, waitForReply, type StuckAction } from './decide-stuck'
2
- import { runBudgetDetail, runStuckComment } from './stuck-text'
2
+ import {
3
+ pullRequestStuckComment,
4
+ runBudgetDetail,
5
+ runStuckComment,
6
+ } from './stuck-text'
3
7
 
4
8
  import type { JournalRecord, RunStuckReason } from '../journal/journal-record'
5
9
  import type { RunState } from '../journal/replay'
@@ -19,7 +23,9 @@ export type RunBudgetAction =
19
23
  * reason "run budget": that is its only step, and nothing new starts. The
20
24
  * spec issue is told, then the run waits for the owner: a bare `retry`
21
25
  * adds one more full budget (replay does that), and `stop` ends the run.
22
- * The owner's other replies wait until the run carries on. `null` when
26
+ * The owner's other replies wait until the run carries on. A run whose PR
27
+ * didn't open (#508) is stuck the same way, told why, and a `retry` tries
28
+ * the PR again, adding no budget. `null` when
23
29
  * the budget has nothing to say: the run is under it, stopping, or its PR
24
30
  * is open.
25
31
  *
@@ -56,7 +62,10 @@ export const decideRunBudget = ({
56
62
  {
57
63
  type: 'report_run_stuck',
58
64
  spec_number,
59
- body: runStuckComment({ detail: stuck.detail, budget }),
65
+ body:
66
+ stuck.reason === 'pull_request_failed'
67
+ ? pullRequestStuckComment({ detail: stuck.detail })
68
+ : runStuckComment({ detail: stuck.detail, budget }),
60
69
  },
61
70
  ]
62
71
  }
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { mkdir } from 'node:fs/promises'
2
+ import { mkdir, rm } from 'node:fs/promises'
3
3
  import { basename, dirname, join } from 'node:path'
4
4
 
5
5
  import difference from 'lodash/difference'
@@ -11,8 +11,6 @@ import { CHANGESET_CONFIG } from './changeset'
11
11
  import { mayEditTests, type BuildAction } from './decide-build'
12
12
  import type { FinalReviewAction } from './decide-final-review'
13
13
  import { retryTicket } from './execute-stuck'
14
- import { openTicketsNotInRun } from './not-in-run'
15
- import { withNotInRun } from './pull-request-text'
16
14
  import { closeSessions, openSessionsIn } from './session-close'
17
15
 
18
16
  import type { AgentLauncher, AgentTurn } from '../agents/agent-launcher'
@@ -25,7 +23,13 @@ import { bunTestCommands, type EngineConfig } from '../config/engine-config'
25
23
  import { checkAlreadyDone } from '../gates/already-done-check'
26
24
  import { prepareCheck, runGates, shellCheck } from '../gates/gate-runner'
27
25
  import { endedText } from '../gates/gate-schemas'
28
- import { newCodeFiles, importStem, scanLeftovers } from '../gates/leftover-scan'
26
+ import {
27
+ importStem,
28
+ isAgentChangeset,
29
+ newCodeFiles,
30
+ scanLeftovers,
31
+ scratchNamedFiles,
32
+ } from '../gates/leftover-scan'
29
33
  import {
30
34
  dependenciesChanged,
31
35
  installCommand,
@@ -450,6 +454,65 @@ const isUsed = async ({
450
454
  )
451
455
  }
452
456
 
457
+ /**
458
+ * Whether a file other than itself and the tests names this scratch-named
459
+ * file by its basename, as a TOC, `package.json`, or a config does: a
460
+ * tracked file, or one this change adds (a new addon's TOC names its new
461
+ * modules). A mention in a test doesn't make a file the repo's.
462
+ */
463
+ const isNamed = async ({
464
+ context,
465
+ cwd,
466
+ path,
467
+ added_texts,
468
+ }: {
469
+ context: BuildContext
470
+ cwd: string
471
+ path: string
472
+ added_texts: Record<string, string>
473
+ }): Promise<boolean> => {
474
+ const name = basename(path)
475
+ const tracked = await context.git.filesMentioning({ cwd, text: name })
476
+ const added = Object.entries(added_texts)
477
+ .filter(([, text]) => text.includes(name))
478
+ .map(([file]) => file)
479
+ const files = [...tracked, ...added].filter((file) => file !== path)
480
+ const tests = testFilesAmong({
481
+ files,
482
+ test_file_patterns: context.config.test_file_patterns,
483
+ })
484
+ return files.some((file) => !tests.includes(file))
485
+ }
486
+
487
+ /**
488
+ * In a repo with changesets, removes the changesets an agent wrote (see
489
+ * `isAgentChangeset`) from the worktree and journals `changeset_dropped`:
490
+ * the engine writes the run's one itself. Returns the other changes.
491
+ */
492
+ export const dropAgentChangesets = async ({
493
+ context,
494
+ cwd,
495
+ ticket,
496
+ changes,
497
+ }: {
498
+ context: BuildContext
499
+ cwd: string
500
+ ticket: number | null
501
+ changes: FileChange[]
502
+ }): Promise<FileChange[]> => {
503
+ if (!context.state.changesets) return changes
504
+ const paths = changes.filter(isAgentChangeset).map(({ path }) => path)
505
+ if (paths.length === 0) return changes
506
+ for (const path of paths) await rm(join(cwd, path), { force: true })
507
+ context.journal.append({
508
+ kind: 'changeset_dropped',
509
+ ticket,
510
+ role: null,
511
+ content: { paths },
512
+ })
513
+ return changes.filter(({ path }) => !paths.includes(path))
514
+ }
515
+
453
516
  /**
454
517
  * The commit a crash left behind at `cwd`: HEAD carries `message` and no
455
518
  * record names it yet, so the step's first try made it and died before
@@ -483,7 +546,8 @@ const leftCommit = async ({
483
546
  * The leftover scan, then an engine commit of everything in `cwd`, both
484
547
  * journaled. What the prepare command made (`prepare_made`) and untracked
485
548
  * links pointing outside the checkout (see `leaveOutIn`) are neither
486
- * scanned nor committed. A hit blocks the commit. A redo after a crash that
549
+ * scanned nor committed. An agent's changeset is dropped before the scan
550
+ * (see `dropAgentChangesets`). A hit blocks the commit. A redo after a crash that
487
551
  * already committed (nothing left to commit, and HEAD is an unjournaled
488
552
  * commit with this message) journals that commit instead of making
489
553
  * another; its scan, of a clean worktree, finds nothing, as the first try's
@@ -514,7 +578,12 @@ export const commitIn = async ({
514
578
  mention_text: string
515
579
  }) => {
516
580
  const leave_out = await leaveOutIn({ context, cwd, ticket })
517
- const changes = await ticketChanges({ context, cwd, leave_out })
581
+ const changes = await dropAgentChangesets({
582
+ context,
583
+ cwd,
584
+ ticket,
585
+ changes: await ticketChanges({ context, cwd, leave_out }),
586
+ })
518
587
  const test_files = testFilesAmong({
519
588
  files: changes.map(({ path }) => path),
520
589
  test_file_patterns: context.config.test_file_patterns,
@@ -528,7 +597,19 @@ export const commitIn = async ({
528
597
  for (const path of newCodeFiles({ changes, test_files })) {
529
598
  used_code[path] = await isUsed({ context, cwd, path, added_texts })
530
599
  }
531
- const hits = scanLeftovers({ changes, test_files, mention_text, used_code })
600
+ const named: string[] = []
601
+ for (const path of scratchNamedFiles({ changes })) {
602
+ if (await isNamed({ context, cwd, path, added_texts })) {
603
+ named.push(path)
604
+ }
605
+ }
606
+ const hits = scanLeftovers({
607
+ changes,
608
+ test_files,
609
+ mention_text,
610
+ used_code,
611
+ named,
612
+ })
532
613
  context.journal.append({
533
614
  kind: 'leftover_scan',
534
615
  ticket,
@@ -1151,12 +1232,15 @@ export const executeBuildAction = async ({
1151
1232
  }: BuildDeps & {
1152
1233
  /**
1153
1234
  * The final review's steps go to `executeFinalReviewAction`, the
1154
- * changeset to `writeChangeset`, and an already-done ticket's to
1155
- * `executeAlreadyDoneAction`.
1235
+ * changeset to `writeChangeset`, the PR to `openPullRequest`, and an
1236
+ * already-done ticket's to `executeAlreadyDoneAction`.
1156
1237
  */
1157
1238
  action: Exclude<
1158
1239
  BuildAction,
1159
- FinalReviewAction | AlreadyDoneAction | { type: 'write_changeset' }
1240
+ | FinalReviewAction
1241
+ | AlreadyDoneAction
1242
+ | { type: 'write_changeset' }
1243
+ | { type: 'open_pull_request' }
1160
1244
  >
1161
1245
  journal: Journal
1162
1246
  tracker: Tracker
@@ -1398,7 +1482,7 @@ export const executeBuildAction = async ({
1398
1482
  case 'report_final_review_stuck':
1399
1483
  case 'ship_final_review':
1400
1484
  case 'retry_final_review':
1401
- // Tracker-only steps; `executeAction` carries them out.
1485
+ // Stuck work's steps; `executeAction` carries them out.
1402
1486
  throw new Error(`${action.type} is not a git or agent step.`)
1403
1487
  case 'mark_stuck':
1404
1488
  // A join crashes cut off too often leaves no half of it behind.
@@ -1418,36 +1502,6 @@ export const executeBuildAction = async ({
1418
1502
  content: { reason: action.reason, detail: action.detail },
1419
1503
  })
1420
1504
  return
1421
- case 'open_pull_request': {
1422
- const { head, base, title, body } = action
1423
- // A redo adopts the PR its first try opened before the crash.
1424
- const adopted = context.step.redo
1425
- ? await tracker.findOpenPullRequest({ head })
1426
- : null
1427
- // The spec's open tickets not in the run are named in the PR.
1428
- const full = withNotInRun({
1429
- body,
1430
- open: await openTicketsNotInRun({
1431
- tracker,
1432
- state: context.state,
1433
- }),
1434
- })
1435
- const opened =
1436
- adopted ??
1437
- (await tracker.openPullRequest({
1438
- head,
1439
- base,
1440
- title,
1441
- body: full,
1442
- }))
1443
- journal.append({
1444
- kind: 'pull_request_opened',
1445
- ticket: null,
1446
- role: null,
1447
- content: { ...opened, head, base, title, body: full },
1448
- })
1449
- return
1450
- }
1451
1505
  case 'done':
1452
1506
  case 'invalid_journal':
1453
1507
  return
@@ -12,7 +12,7 @@ import {
12
12
  type WorkspacePackage,
13
13
  } from './changeset'
14
14
  import type { BuildAction } from './decide-build'
15
- import { leaveOutIn, need, type BuildContext } from './execute-build'
15
+ import { need, type BuildContext } from './execute-build'
16
16
 
17
17
  /** The part of the changesets config the engine reads: what it ignores. */
18
18
  const ChangesetConfigSchema = z
@@ -83,8 +83,9 @@ export const workspacePackages = async ({
83
83
  }
84
84
 
85
85
  /**
86
- * Writes the run's changeset on the run branch, commits it, and pushes the
87
- * run branch, then journals `changeset_written`. It names every workspace
86
+ * Writes the run's changeset on the run branch, commits it (only it: other
87
+ * uncommitted edits stay out, #509), and pushes the run branch, then
88
+ * journals `changeset_written`. It names every workspace
88
89
  * package with files changed since the run branch's base, minus the ones
89
90
  * the config ignores. A redo after a crash finds its own file already
90
91
  * committed and commits nothing twice. A `release:none` spec (#476), or a
@@ -145,11 +146,8 @@ export const writeChangeset = async ({
145
146
  await Bun.write(join(cwd, path), changesetText({ packages, bump, summary }))
146
147
  const changes = await git.changes({ cwd })
147
148
  if (changes.some((change) => change.path === path)) {
148
- await git.commitAll({
149
- cwd,
150
- message,
151
- leave_out: await leaveOutIn({ context, cwd, ticket: null }),
152
- })
149
+ // Only the changeset: any other edit left in the worktree stays out.
150
+ await git.commitPaths({ cwd, message, paths: [path] })
153
151
  }
154
152
  await git.push({ cwd, branch })
155
153
  journal.append({
@@ -4,15 +4,22 @@ import { isAbsolute, join } from 'node:path'
4
4
  import type { FinalReviewAction } from './decide-final-review'
5
5
  import {
6
6
  commitIn,
7
+ dropAgentChangesets,
7
8
  gatesIn,
9
+ leaveOutIn,
8
10
  need,
9
11
  runTurn,
10
12
  type BuildContext,
11
13
  } from './execute-build'
12
14
  import { closeSessions, openSessionsIn } from './session-close'
13
15
 
16
+ import type { EngineCommit } from '../git/git-adapter'
14
17
  import type { Journal } from '../journal/journal'
15
- import { replayRun, type RuleFile } from '../journal/replay'
18
+ import {
19
+ replayRun,
20
+ type FinalReviewState,
21
+ type RuleFile,
22
+ } from '../journal/replay'
16
23
 
17
24
  /**
18
25
  * Carries out the final review's steps on the run branch's worktree: its
@@ -293,7 +300,8 @@ export const executeFinalReviewAction = async ({
293
300
 
294
301
  /**
295
302
  * The seam for replies (#366): when the person replies `ship` to a stuck
296
- * final review, the reply reader calls this. It journals
303
+ * final review, the reply reader calls this (the engine's ship step,
304
+ * `shipFinalReviewIn`, after committing any edits left over). It journals
297
305
  * `final_review_shipped`, and the next `runEngine` on the journal opens the
298
306
  * PR with the findings still open listed at the top of its description.
299
307
  * A final review that isn't stuck (or was already shipped) gets nothing.
@@ -304,21 +312,89 @@ export const executeFinalReviewAction = async ({
304
312
  */
305
313
  export const shipFinalReview = ({
306
314
  journal,
315
+ commit,
307
316
  }: {
308
317
  journal: Journal
318
+ /** The commit of the edits left uncommitted at ship time, if any. */
319
+ commit?: EngineCommit & { message: string }
309
320
  }): { ok: true } | { ok: false; reason: string } => {
310
- const { final_review } = replayRun({ records: journal.read() })
311
- if (final_review.stuck === null) {
312
- return { ok: false, reason: 'The final review is not stuck.' }
313
- }
314
- if (final_review.shipped) {
315
- return { ok: false, reason: 'The final review was already shipped.' }
316
- }
321
+ const refused = shipRefusal({
322
+ final_review: replayRun({ records: journal.read() }).final_review,
323
+ })
324
+ if (refused !== null) return { ok: false, reason: refused }
317
325
  journal.append({
318
326
  kind: 'final_review_shipped',
319
327
  ticket: null,
320
328
  role: null,
321
- content: {},
329
+ content: commit === undefined ? {} : { commit },
322
330
  })
323
331
  return { ok: true }
324
332
  }
333
+
334
+ /** Why a final review can't be shipped, or `null` when it can. */
335
+ const shipRefusal = ({
336
+ final_review,
337
+ }: {
338
+ final_review: FinalReviewState
339
+ }): string | null => {
340
+ if (final_review.stuck === null) return 'The final review is not stuck.'
341
+ if (final_review.shipped) return 'The final review was already shipped.'
342
+ return null
343
+ }
344
+
345
+ /**
346
+ * The commit message for the edits left uncommitted in the run branch's
347
+ * worktree when a stuck final review is shipped (#509).
348
+ *
349
+ * @example
350
+ * shippedEditsMessage({ round: 4 }) // 'fix: final review round 4, shipped with open findings'
351
+ */
352
+ export const shippedEditsMessage = ({ round }: { round: number }): string =>
353
+ `fix: final review round ${round}, shipped with open findings`
354
+
355
+ /**
356
+ * Carries out a `ship` reply to the stuck final review (#509): edits left
357
+ * uncommitted in the run branch's worktree (a fixer's, or the owner's) get
358
+ * their own commit (`shippedEditsMessage`), which is pushed, then
359
+ * `shipFinalReview` journals it with the ship, so the PR lists its files as
360
+ * not reviewed. What prepare made stays out, and a changeset a fixer wrote
361
+ * is dropped as at any engine commit (`dropAgentChangesets`), so the PR has
362
+ * only the engine's. With no edits, no commit. A redo after a crash that
363
+ * already committed adopts that commit.
364
+ */
365
+ export const shipFinalReviewIn = async ({
366
+ context,
367
+ }: {
368
+ context: BuildContext
369
+ }): Promise<void> => {
370
+ const { journal, git, state } = context
371
+ const refused = shipRefusal({ final_review: state.final_review })
372
+ if (refused !== null) throw new Error(refused)
373
+ const { path: cwd, branch } = need({
374
+ value: state.run_branch,
375
+ what: 'run branch',
376
+ })
377
+ const message = shippedEditsMessage({ round: state.final_review.round })
378
+ const leave_out = await leaveOutIn({ context, cwd, ticket: null })
379
+ const changes = await dropAgentChangesets({
380
+ context,
381
+ cwd,
382
+ ticket: null,
383
+ changes: (await git.changes({ cwd })).filter(
384
+ ({ path }) => !leave_out.includes(path)
385
+ ),
386
+ })
387
+ let commit: (EngineCommit & { message: string }) | undefined
388
+ if (changes.length > 0) {
389
+ commit = {
390
+ ...(await git.commitAll({ cwd, message, leave_out })),
391
+ message,
392
+ }
393
+ } else if (context.step.redo) {
394
+ const last = await git.lastCommit({ cwd })
395
+ if (last.message === message) commit = last
396
+ }
397
+ if (commit !== undefined) await git.push({ cwd, branch })
398
+ const shipped = shipFinalReview({ journal, commit })
399
+ if (!shipped.ok) throw new Error(shipped.reason)
400
+ }
@@ -0,0 +1,84 @@
1
+ import { join } from 'node:path'
2
+
3
+ import type { BuildAction } from './decide-build'
4
+ import type { BuildContext } from './execute-build'
5
+ import { openTicketsNotInRun } from './not-in-run'
6
+ import {
7
+ fitPullRequestBody,
8
+ PULL_REQUEST_FILE,
9
+ withNotInRun,
10
+ } from './pull-request-text'
11
+
12
+ import { postCommentOnce } from '../tracker/post-comment-once'
13
+ import { isTrackerFailure, type OpenedPullRequest } from '../tracker/tracker'
14
+
15
+ const errorText = (error: unknown): string =>
16
+ error instanceof Error ? error.message : String(error)
17
+
18
+ /**
19
+ * Opens the run's one pull request, then journals `pull_request_opened`
20
+ * (#508). Its full text (with the spec's open tickets not in the run) is
21
+ * saved first to `pull-request.md` in the run folder, so it's never lost.
22
+ * An open PR from the run branch is reused, never opened twice: the one a
23
+ * try opened before a crash or a failure, or one the owner opened by hand.
24
+ * The body sent keeps under `PULL_REQUEST_BUDGET`, and what doesn't fit
25
+ * is posted as PR comments after it (`fitPullRequestBody`), each once. A
26
+ * failed tracker call (`isTrackerFailure`: finding or opening the PR, such
27
+ * as a failed `gh pr create`) journals the run stuck (`pull_request_failed`)
28
+ * with its error, for the owner's `retry`; any other error is a crash.
29
+ */
30
+ export const openPullRequest = async ({
31
+ context,
32
+ action,
33
+ }: {
34
+ context: BuildContext
35
+ action: Extract<BuildAction, { type: 'open_pull_request' }>
36
+ }): Promise<void> => {
37
+ const { journal, tracker, state, run_dir, step } = context
38
+ const { head, base, title, body } = action
39
+ // The spec's open tickets not in the run are named in the PR.
40
+ const full = withNotInRun({
41
+ body,
42
+ open: await openTicketsNotInRun({ tracker, state }),
43
+ })
44
+ await Bun.write(join(run_dir, PULL_REQUEST_FILE), full)
45
+ const fitted = fitPullRequestBody({ body: full })
46
+ let opened: OpenedPullRequest
47
+ try {
48
+ opened =
49
+ (await tracker.findOpenPullRequest({ head })) ??
50
+ (await tracker.openPullRequest({
51
+ head,
52
+ base,
53
+ title,
54
+ body: fitted.body,
55
+ }))
56
+ } catch (error) {
57
+ if (!isTrackerFailure(error)) throw error
58
+ journal.append({
59
+ kind: 'run_stuck',
60
+ ticket: null,
61
+ role: null,
62
+ content: {
63
+ reason: 'pull_request_failed',
64
+ detail: errorText(error),
65
+ },
66
+ })
67
+ return
68
+ }
69
+ for (const [n, comment] of fitted.comments.entries()) {
70
+ await postCommentOnce({
71
+ tracker,
72
+ number: opened.number,
73
+ body: comment,
74
+ step,
75
+ n,
76
+ })
77
+ }
78
+ journal.append({
79
+ kind: 'pull_request_opened',
80
+ ticket: null,
81
+ role: null,
82
+ content: { ...opened, head, base, title, body: full },
83
+ })
84
+ }