@markjaquith/agency 2.48.1 → 2.50.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 +40 -9
- package/cli.ts +32 -0
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +141 -38
- package/src/cli-parser.test.ts +120 -0
- package/src/cli-parser.ts +122 -7
- package/src/cli.test.ts +71 -0
- package/src/commands/claim.ts +26 -2
- package/src/commands/phase.ts +23 -2
- package/src/commands/review.ts +38 -0
- package/src/commands/task.ts +49 -7
- package/src/commands/work.test.ts +2 -0
- package/src/commands/work.ts +2 -2
- package/src/graph-schema.ts +35 -13
- package/src/protocol.ts +1 -0
- package/src/services/ArchiveService.test.ts +2 -2
- package/src/services/ArchiveService.ts +1 -0
- package/src/services/ClaimService.test.ts +59 -0
- package/src/services/ClaimService.ts +36 -2
- package/src/services/ContextService.ts +56 -4
- package/src/services/DoctorService.ts +45 -1
- package/src/services/GraphMutationService.ts +21 -0
- package/src/services/GraphService.ts +118 -54
- package/src/services/IntegrationService.test.ts +4 -1
- package/src/services/PhaseService.ts +55 -3
- package/src/services/PullRequestService.test.ts +22 -2
- package/src/services/PullRequestService.ts +44 -3
- package/src/services/ReadinessService.ts +7 -3
- package/src/services/ReviewService.test.ts +472 -0
- package/src/services/ReviewService.ts +404 -0
- package/src/services/SyncService.test.ts +68 -2
- package/src/services/SyncService.ts +123 -6
- package/src/services/TaskPhaseService.test.ts +182 -0
- package/src/services/TaskService.ts +128 -11
- package/src/services/WorkbaseService.test.ts +58 -0
- package/src/services/WorkbaseService.ts +22 -1
- package/src/services/WorktreeService.test.ts +16 -16
- package/src/services/WorktreeService.ts +76 -40
- package/src/test-utils.ts +2 -0
- package/src/work-view.ts +14 -6
- package/src/workbase/AGENTS.md +7 -2
- package/src/workbase/completion.ts +28 -0
- package/src/workbase/schemas.test.ts +57 -0
- package/src/workbase/schemas.ts +65 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { Data, Effect, Layer } from "effect"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import { lstat, mkdir } from "node:fs/promises"
|
|
4
|
+
import { dirname } from "node:path"
|
|
5
|
+
import { FileSystemService } from "./FileSystemService"
|
|
6
|
+
import { PhaseService } from "./PhaseService"
|
|
7
|
+
import { RepositoryService } from "./RepositoryService"
|
|
8
|
+
import { TaskService } from "./TaskService"
|
|
9
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
10
|
+
import {
|
|
11
|
+
WorktreeService,
|
|
12
|
+
type WorktreeRemovalSnapshot,
|
|
13
|
+
} from "./WorktreeService"
|
|
14
|
+
import { withWorktreeLocks } from "./WorktreeLock"
|
|
15
|
+
import {
|
|
16
|
+
documentWriteStep,
|
|
17
|
+
runLifecycleTransaction,
|
|
18
|
+
type TransactionStep,
|
|
19
|
+
} from "./LifecycleTransaction"
|
|
20
|
+
import { RevisionConflictError } from "../workbase/document-revision"
|
|
21
|
+
import {
|
|
22
|
+
formatMarkdownDocument,
|
|
23
|
+
parseFrontmatter,
|
|
24
|
+
} from "../workbase/frontmatter"
|
|
25
|
+
import type { ReviewRecord, ReviewSource } from "../workbase/schemas"
|
|
26
|
+
|
|
27
|
+
class ReviewError extends Data.TaggedError("ReviewError")<{
|
|
28
|
+
readonly message: string
|
|
29
|
+
readonly cause?: unknown
|
|
30
|
+
}> {}
|
|
31
|
+
|
|
32
|
+
const githubRepository = (remote: string) => {
|
|
33
|
+
const match = remote
|
|
34
|
+
.replace(/\.git\/?$/, "")
|
|
35
|
+
.match(/(?:github\.com[/:])([^/]+\/[^/]+)$/i)
|
|
36
|
+
return match?.[1]?.toLowerCase() ?? null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const pinRef = (taskId: string) =>
|
|
40
|
+
`refs/agency/reviews/${Buffer.from(taskId).toString("hex")}`
|
|
41
|
+
|
|
42
|
+
const runGit = async (args: readonly string[]) => {
|
|
43
|
+
const child = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
|
|
44
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
45
|
+
child.exited,
|
|
46
|
+
new Response(child.stdout).text(),
|
|
47
|
+
new Response(child.stderr).text(),
|
|
48
|
+
])
|
|
49
|
+
if (exitCode !== 0) throw new Error(stderr.trim() || args.join(" "))
|
|
50
|
+
return stdout.trim()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const WorktreeLayer = Layer.mergeAll(
|
|
54
|
+
FileSystemService.Default,
|
|
55
|
+
WorkbaseService.Default,
|
|
56
|
+
TaskService.Default,
|
|
57
|
+
PhaseService.Default,
|
|
58
|
+
WorktreeService.Default,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const runWorktreeEffect = <A, E>(effect: Effect.Effect<A, E, any>) =>
|
|
62
|
+
Effect.runPromise(
|
|
63
|
+
effect.pipe(Effect.provide(WorktreeLayer)) as Effect.Effect<A, E, never>,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const restoreSnapshots = async (
|
|
67
|
+
snapshots: readonly WorktreeRemovalSnapshot[],
|
|
68
|
+
) => {
|
|
69
|
+
for (const snapshot of snapshots) {
|
|
70
|
+
try {
|
|
71
|
+
await lstat(snapshot.path)
|
|
72
|
+
continue
|
|
73
|
+
} catch {}
|
|
74
|
+
await mkdir(dirname(snapshot.path), { recursive: true })
|
|
75
|
+
await runGit(
|
|
76
|
+
snapshot.branch
|
|
77
|
+
? [
|
|
78
|
+
"git",
|
|
79
|
+
"-C",
|
|
80
|
+
snapshot.repositoryPath,
|
|
81
|
+
"worktree",
|
|
82
|
+
"add",
|
|
83
|
+
snapshot.path,
|
|
84
|
+
snapshot.branch,
|
|
85
|
+
]
|
|
86
|
+
: [
|
|
87
|
+
"git",
|
|
88
|
+
"-C",
|
|
89
|
+
snapshot.repositoryPath,
|
|
90
|
+
"worktree",
|
|
91
|
+
"add",
|
|
92
|
+
"--detach",
|
|
93
|
+
snapshot.path,
|
|
94
|
+
snapshot.head,
|
|
95
|
+
],
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const normalizeBranch = (input: string, repositoryPath: string) =>
|
|
101
|
+
Effect.gen(function* () {
|
|
102
|
+
const fs = yield* FileSystemService
|
|
103
|
+
if (
|
|
104
|
+
input.startsWith("refs/") &&
|
|
105
|
+
!input.startsWith("refs/heads/") &&
|
|
106
|
+
!input.startsWith("refs/remotes/origin/")
|
|
107
|
+
) {
|
|
108
|
+
return yield* new ReviewError({
|
|
109
|
+
message: `Invalid review branch '${input}'`,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
const name = input
|
|
113
|
+
.replace(/^refs\/remotes\/origin\//, "")
|
|
114
|
+
.replace(/^origin\//, "")
|
|
115
|
+
.replace(/^refs\/heads\//, "")
|
|
116
|
+
if (
|
|
117
|
+
!name ||
|
|
118
|
+
name === "HEAD" ||
|
|
119
|
+
name.startsWith("-") ||
|
|
120
|
+
/[\s:*?\[\\^~]/.test(name) ||
|
|
121
|
+
name.includes("..") ||
|
|
122
|
+
name.includes("@{")
|
|
123
|
+
) {
|
|
124
|
+
return yield* new ReviewError({
|
|
125
|
+
message: `Invalid review branch '${input}'`,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
const checked = yield* fs.runCommand(
|
|
129
|
+
["git", "-C", repositoryPath, "check-ref-format", "--branch", name],
|
|
130
|
+
{ captureOutput: true },
|
|
131
|
+
)
|
|
132
|
+
if (checked.exitCode !== 0) {
|
|
133
|
+
return yield* new ReviewError({
|
|
134
|
+
message: `Invalid review branch '${input}'`,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
return `refs/heads/${name}`
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const fetchCommit = (repoPath: string, sourceRef: string) =>
|
|
141
|
+
Effect.gen(function* () {
|
|
142
|
+
const fs = yield* FileSystemService
|
|
143
|
+
const temporaryRef = `refs/agency/review-fetch/${process.pid}-${randomUUID()}`
|
|
144
|
+
const fetched = yield* fs.runCommand(
|
|
145
|
+
[
|
|
146
|
+
"git",
|
|
147
|
+
"-C",
|
|
148
|
+
repoPath,
|
|
149
|
+
"fetch",
|
|
150
|
+
"--no-tags",
|
|
151
|
+
"origin",
|
|
152
|
+
`+${sourceRef}:${temporaryRef}`,
|
|
153
|
+
],
|
|
154
|
+
{ captureOutput: true },
|
|
155
|
+
)
|
|
156
|
+
if (fetched.exitCode !== 0) {
|
|
157
|
+
const cleanup = yield* fs.runCommand(
|
|
158
|
+
["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
|
|
159
|
+
{ captureOutput: true },
|
|
160
|
+
)
|
|
161
|
+
return yield* new ReviewError({
|
|
162
|
+
message: `Review source '${sourceRef}' could not be fetched: ${fetched.stderr.trim()}${cleanup.exitCode === 0 ? "" : `; temporary ref cleanup failed: ${cleanup.stderr.trim()}`}`,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
const resolved = yield* fs.runCommand(
|
|
166
|
+
[
|
|
167
|
+
"git",
|
|
168
|
+
"-C",
|
|
169
|
+
repoPath,
|
|
170
|
+
"rev-parse",
|
|
171
|
+
"--verify",
|
|
172
|
+
`${temporaryRef}^{commit}`,
|
|
173
|
+
],
|
|
174
|
+
{ captureOutput: true },
|
|
175
|
+
)
|
|
176
|
+
const cleanup = yield* fs.runCommand(
|
|
177
|
+
["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
|
|
178
|
+
{ captureOutput: true },
|
|
179
|
+
)
|
|
180
|
+
if (cleanup.exitCode !== 0) {
|
|
181
|
+
return yield* new ReviewError({
|
|
182
|
+
message: `Failed to remove temporary review fetch ref: ${cleanup.stderr.trim()}`,
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
const commit = resolved.stdout.trim()
|
|
186
|
+
if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
|
|
187
|
+
return yield* new ReviewError({
|
|
188
|
+
message: `Review source '${sourceRef}' did not resolve to a commit`,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
return commit
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
export class ReviewService extends Effect.Service<ReviewService>()(
|
|
195
|
+
"ReviewService",
|
|
196
|
+
{
|
|
197
|
+
sync: () => ({
|
|
198
|
+
resolve: (
|
|
199
|
+
repo: string,
|
|
200
|
+
input: { readonly pullRequest?: string; readonly ref?: string },
|
|
201
|
+
startPath: string = process.cwd(),
|
|
202
|
+
) =>
|
|
203
|
+
Effect.gen(function* () {
|
|
204
|
+
const repositories = yield* RepositoryService
|
|
205
|
+
const repository = yield* repositories.show(repo, startPath)
|
|
206
|
+
if (!repository.remote || repository.states.includes("missing")) {
|
|
207
|
+
return yield* new ReviewError({
|
|
208
|
+
message: `Repository alias '${repo}' must be materialized with an origin remote`,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
let source: ReviewSource
|
|
212
|
+
let sourceRef: string
|
|
213
|
+
if (input.pullRequest) {
|
|
214
|
+
const urlMatch = input.pullRequest.match(
|
|
215
|
+
/^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)\/?$/i,
|
|
216
|
+
)
|
|
217
|
+
const identifier =
|
|
218
|
+
urlMatch?.[2] ??
|
|
219
|
+
(/^\d+$/.test(input.pullRequest) ? input.pullRequest : null)
|
|
220
|
+
if (!identifier) {
|
|
221
|
+
return yield* new ReviewError({
|
|
222
|
+
message: `Invalid GitHub pull request '${input.pullRequest}'`,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
const originRepository = githubRepository(repository.remote)
|
|
226
|
+
if (!originRepository) {
|
|
227
|
+
return yield* new ReviewError({
|
|
228
|
+
message: `Repository alias '${repo}' does not use a GitHub origin`,
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
if (urlMatch && urlMatch[1]!.toLowerCase() !== originRepository) {
|
|
232
|
+
return yield* new ReviewError({
|
|
233
|
+
message: `Pull request repository '${urlMatch[1]}' does not match alias '${repo}' origin '${originRepository}'`,
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
sourceRef = `refs/pull/${identifier}/head`
|
|
237
|
+
source = {
|
|
238
|
+
kind: "pull-request",
|
|
239
|
+
provider: "github",
|
|
240
|
+
repository: originRepository,
|
|
241
|
+
identifier,
|
|
242
|
+
url: `https://github.com/${originRepository}/pull/${identifier}`,
|
|
243
|
+
fetchRef: sourceRef,
|
|
244
|
+
}
|
|
245
|
+
} else if (input.ref) {
|
|
246
|
+
sourceRef = yield* normalizeBranch(input.ref, repository.path)
|
|
247
|
+
source = { kind: "branch", ref: sourceRef }
|
|
248
|
+
} else {
|
|
249
|
+
return yield* new ReviewError({
|
|
250
|
+
message: "Exactly one review source is required",
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
const commit = yield* fetchCommit(repository.path, sourceRef)
|
|
254
|
+
return {
|
|
255
|
+
repo,
|
|
256
|
+
source,
|
|
257
|
+
commit,
|
|
258
|
+
refreshedAt: new Date().toISOString(),
|
|
259
|
+
} satisfies ReviewRecord
|
|
260
|
+
}),
|
|
261
|
+
|
|
262
|
+
refresh: (
|
|
263
|
+
taskId: string,
|
|
264
|
+
startPath: string = process.cwd(),
|
|
265
|
+
ifRevision?: string,
|
|
266
|
+
) =>
|
|
267
|
+
Effect.gen(function* () {
|
|
268
|
+
const workbase = yield* WorkbaseService
|
|
269
|
+
const tasks = yield* TaskService
|
|
270
|
+
const worktrees = yield* WorktreeService
|
|
271
|
+
const service = yield* ReviewService
|
|
272
|
+
const repositories = yield* RepositoryService
|
|
273
|
+
const root = yield* workbase.discover(startPath)
|
|
274
|
+
return yield* withWorktreeLocks(
|
|
275
|
+
root,
|
|
276
|
+
[{ taskId }],
|
|
277
|
+
Effect.gen(function* () {
|
|
278
|
+
const task = yield* tasks.show(taskId, root)
|
|
279
|
+
if (!("review" in task.data)) {
|
|
280
|
+
return yield* new ReviewError({
|
|
281
|
+
message: `Task '${taskId}' is not a review task`,
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
const previousReview = task.data.review
|
|
285
|
+
if (task.data.claim?.state === "active") {
|
|
286
|
+
return yield* new ReviewError({
|
|
287
|
+
message: `Review task '${taskId}' has an active claim; release or finish it before refreshing`,
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
if (ifRevision && task.revision !== ifRevision) {
|
|
291
|
+
return yield* new RevisionConflictError({
|
|
292
|
+
path: task.path,
|
|
293
|
+
target: `task '${taskId}'`,
|
|
294
|
+
expectedRevision: ifRevision,
|
|
295
|
+
currentRevision: task.revision,
|
|
296
|
+
message: `Revision conflict for task '${taskId}'`,
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
const inspection = yield* worktrees.inspect(
|
|
300
|
+
taskId,
|
|
301
|
+
undefined,
|
|
302
|
+
root,
|
|
303
|
+
)
|
|
304
|
+
if (
|
|
305
|
+
inspection.conflicts.length ||
|
|
306
|
+
inspection.checkouts.some((checkout) => checkout.dirty)
|
|
307
|
+
) {
|
|
308
|
+
return yield* new ReviewError({
|
|
309
|
+
message: `Cannot refresh review task '${taskId}'; its checkout is dirty or structurally unexpected`,
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
const latest = yield* service.resolve(
|
|
313
|
+
task.data.review.repo,
|
|
314
|
+
task.data.review.source.kind === "pull-request"
|
|
315
|
+
? { pullRequest: task.data.review.source.url }
|
|
316
|
+
: { ref: task.data.review.source.ref },
|
|
317
|
+
root,
|
|
318
|
+
)
|
|
319
|
+
const parsed = yield* parseFrontmatter(task.content, task.path)
|
|
320
|
+
const content = formatMarkdownDocument(
|
|
321
|
+
{ ...task.data, review: latest },
|
|
322
|
+
parsed.body,
|
|
323
|
+
)
|
|
324
|
+
const repository = yield* repositories.show(
|
|
325
|
+
task.data.review.repo,
|
|
326
|
+
root,
|
|
327
|
+
)
|
|
328
|
+
const hadCheckout = inspection.checkouts.some(
|
|
329
|
+
(checkout) => checkout.exists || checkout.registered,
|
|
330
|
+
)
|
|
331
|
+
const snapshots: WorktreeRemovalSnapshot[] = []
|
|
332
|
+
const steps: TransactionStep[] = []
|
|
333
|
+
if (hadCheckout) {
|
|
334
|
+
steps.push({
|
|
335
|
+
label: `remove review checkout for ${taskId}`,
|
|
336
|
+
apply: () =>
|
|
337
|
+
runWorktreeEffect(
|
|
338
|
+
worktrees.remove(taskId, undefined, root, {
|
|
339
|
+
snapshots,
|
|
340
|
+
lockHeld: true,
|
|
341
|
+
}),
|
|
342
|
+
).then(() => undefined),
|
|
343
|
+
rollback: () => restoreSnapshots(snapshots),
|
|
344
|
+
manualRecovery: `Restore the detached checkout under ${inspection.codePath}`,
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
steps.push(
|
|
348
|
+
documentWriteStep(root, [{ path: task.path, content }]),
|
|
349
|
+
)
|
|
350
|
+
steps.push({
|
|
351
|
+
label: `advance review pin for ${taskId}`,
|
|
352
|
+
apply: () =>
|
|
353
|
+
runGit([
|
|
354
|
+
"git",
|
|
355
|
+
"-C",
|
|
356
|
+
repository.path,
|
|
357
|
+
"update-ref",
|
|
358
|
+
pinRef(taskId),
|
|
359
|
+
latest.commit,
|
|
360
|
+
previousReview.commit,
|
|
361
|
+
]).then(() => undefined),
|
|
362
|
+
rollback: () =>
|
|
363
|
+
runGit([
|
|
364
|
+
"git",
|
|
365
|
+
"-C",
|
|
366
|
+
repository.path,
|
|
367
|
+
"update-ref",
|
|
368
|
+
pinRef(taskId),
|
|
369
|
+
previousReview.commit,
|
|
370
|
+
latest.commit,
|
|
371
|
+
]).then(() => undefined),
|
|
372
|
+
manualRecovery: `Reset ${pinRef(taskId)} to ${previousReview.commit}`,
|
|
373
|
+
})
|
|
374
|
+
if (hadCheckout) {
|
|
375
|
+
steps.push({
|
|
376
|
+
label: `create refreshed review checkout for ${taskId}`,
|
|
377
|
+
apply: () =>
|
|
378
|
+
runWorktreeEffect(
|
|
379
|
+
worktrees.materialize(taskId, undefined, root, {
|
|
380
|
+
lockHeld: true,
|
|
381
|
+
}),
|
|
382
|
+
).then(() => undefined),
|
|
383
|
+
manualRecovery: `Run agency work prepare for review task '${taskId}'`,
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
yield* runLifecycleTransaction({
|
|
387
|
+
root,
|
|
388
|
+
preconditions: [{ path: task.path, revision: task.revision }],
|
|
389
|
+
steps,
|
|
390
|
+
})
|
|
391
|
+
return {
|
|
392
|
+
taskId,
|
|
393
|
+
previousCommit: task.data.review.commit,
|
|
394
|
+
commit: latest.commit,
|
|
395
|
+
changed: latest.commit !== task.data.review.commit,
|
|
396
|
+
refreshedAt: latest.refreshedAt,
|
|
397
|
+
revision: (yield* tasks.show(taskId, root)).revision,
|
|
398
|
+
}
|
|
399
|
+
}),
|
|
400
|
+
)
|
|
401
|
+
}),
|
|
402
|
+
}),
|
|
403
|
+
},
|
|
404
|
+
) {}
|
|
@@ -8,6 +8,7 @@ import { PullRequestService } from "./PullRequestService"
|
|
|
8
8
|
import { SyncService } from "./SyncService"
|
|
9
9
|
import { TaskService } from "./TaskService"
|
|
10
10
|
import { WorktreeService } from "./WorktreeService"
|
|
11
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
11
12
|
|
|
12
13
|
const git = async (args: string[], cwd?: string) => {
|
|
13
14
|
const process = Bun.spawn(["git", ...args], {
|
|
@@ -582,7 +583,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
|
|
|
582
583
|
),
|
|
583
584
|
),
|
|
584
585
|
)
|
|
585
|
-
await rm(workspace.writablePath
|
|
586
|
+
await rm(workspace.writablePath!, { recursive: true, force: true })
|
|
586
587
|
|
|
587
588
|
const observed = await runTestEffect(
|
|
588
589
|
SyncService.pipe(
|
|
@@ -606,7 +607,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
|
|
|
606
607
|
)
|
|
607
608
|
expect(applied.changes).toEqual([])
|
|
608
609
|
expect(
|
|
609
|
-
await Bun.file(join(workspace.writablePath
|
|
610
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
|
|
610
611
|
).toBe(false)
|
|
611
612
|
})
|
|
612
613
|
|
|
@@ -663,4 +664,69 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
|
|
|
663
664
|
)
|
|
664
665
|
expect(task.data).toMatchObject({ status: "open" })
|
|
665
666
|
})
|
|
667
|
+
|
|
668
|
+
test("leaves non-PR completion unchanged when a matching PR is discoverable", async () => {
|
|
669
|
+
await runTestEffect(
|
|
670
|
+
TaskService.pipe(
|
|
671
|
+
Effect.flatMap((service) =>
|
|
672
|
+
service.create(
|
|
673
|
+
{
|
|
674
|
+
id: "non-pr",
|
|
675
|
+
ticketUrl: null,
|
|
676
|
+
repo: "agency",
|
|
677
|
+
branch: "feat/example",
|
|
678
|
+
base: "main",
|
|
679
|
+
},
|
|
680
|
+
root,
|
|
681
|
+
),
|
|
682
|
+
),
|
|
683
|
+
),
|
|
684
|
+
)
|
|
685
|
+
await runTestEffect(
|
|
686
|
+
TaskService.pipe(
|
|
687
|
+
Effect.flatMap((service) =>
|
|
688
|
+
service.setStatus("non-pr", "done", root, {
|
|
689
|
+
summary: "Investigation completed without changes.",
|
|
690
|
+
}),
|
|
691
|
+
),
|
|
692
|
+
),
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
696
|
+
const applied = await runTestEffect(
|
|
697
|
+
SyncService.pipe(
|
|
698
|
+
Effect.flatMap((service) =>
|
|
699
|
+
service.reconcile({ cwd: root, apply: true }),
|
|
700
|
+
),
|
|
701
|
+
),
|
|
702
|
+
)
|
|
703
|
+
expect(
|
|
704
|
+
applied.changes.some(
|
|
705
|
+
(change) =>
|
|
706
|
+
change.kind === "record-pr" || change.kind === "mark-done",
|
|
707
|
+
),
|
|
708
|
+
).toBe(false)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const task = await runTestEffect(
|
|
712
|
+
TaskService.pipe(
|
|
713
|
+
Effect.flatMap((service) => service.show("non-pr", root)),
|
|
714
|
+
),
|
|
715
|
+
)
|
|
716
|
+
expect(task.data).toMatchObject({
|
|
717
|
+
status: "done",
|
|
718
|
+
pr: null,
|
|
719
|
+
completion: {
|
|
720
|
+
mode: "non-pr",
|
|
721
|
+
summary: "Investigation completed without changes.",
|
|
722
|
+
},
|
|
723
|
+
})
|
|
724
|
+
expect(
|
|
725
|
+
await runTestEffect(
|
|
726
|
+
WorkbaseService.pipe(
|
|
727
|
+
Effect.flatMap((service) => service.validate(root)),
|
|
728
|
+
),
|
|
729
|
+
),
|
|
730
|
+
).toMatchObject({ valid: true, issues: [] })
|
|
731
|
+
})
|
|
666
732
|
})
|
|
@@ -83,11 +83,16 @@ interface CheckoutState {
|
|
|
83
83
|
interface ExecutionSyncState {
|
|
84
84
|
readonly target: string
|
|
85
85
|
readonly status: WorkStatus
|
|
86
|
-
readonly branch: string
|
|
87
|
-
readonly base: string
|
|
86
|
+
readonly branch: string | null
|
|
87
|
+
readonly base: string | null
|
|
88
88
|
readonly claim: ClaimRecord | null
|
|
89
89
|
readonly checkouts: readonly CheckoutState[]
|
|
90
90
|
readonly pr: Record<string, unknown>
|
|
91
|
+
readonly review?: {
|
|
92
|
+
readonly pinnedCommit: string
|
|
93
|
+
readonly sourceCommit: string | null
|
|
94
|
+
readonly sourceAvailable: boolean
|
|
95
|
+
}
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
interface SyncResult {
|
|
@@ -219,7 +224,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
219
224
|
data: phase.data,
|
|
220
225
|
})
|
|
221
226
|
}
|
|
222
|
-
} else {
|
|
227
|
+
} else if (!("review" in task.data)) {
|
|
223
228
|
records.push({
|
|
224
229
|
key: `task:${task.id}`,
|
|
225
230
|
taskId: task.id,
|
|
@@ -551,7 +556,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
551
556
|
},
|
|
552
557
|
root,
|
|
553
558
|
)
|
|
554
|
-
data = expired.data
|
|
559
|
+
data = expired.data as ExecutionData
|
|
555
560
|
revision = expired.revision
|
|
556
561
|
} else {
|
|
557
562
|
const claim: ClaimRecord = {
|
|
@@ -580,6 +585,19 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
580
585
|
})
|
|
581
586
|
}
|
|
582
587
|
|
|
588
|
+
if (data.completion) {
|
|
589
|
+
executions.push({
|
|
590
|
+
target: record.key,
|
|
591
|
+
status: data.status,
|
|
592
|
+
branch: data.branch,
|
|
593
|
+
base: data.base,
|
|
594
|
+
claim: data.claim ?? null,
|
|
595
|
+
checkouts: checkoutStates,
|
|
596
|
+
pr: { url: null, state: "none" },
|
|
597
|
+
})
|
|
598
|
+
continue
|
|
599
|
+
}
|
|
600
|
+
|
|
583
601
|
const existing = data.pr ? normalizePullRequestRecord(data.pr) : null
|
|
584
602
|
let current: PullRequestRecord | null = existing
|
|
585
603
|
let pr: Record<string, unknown> = existing ?? {
|
|
@@ -774,7 +792,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
774
792
|
},
|
|
775
793
|
root,
|
|
776
794
|
)
|
|
777
|
-
data = recorded.data
|
|
795
|
+
data = recorded.data as ExecutionData
|
|
778
796
|
revision = recorded.revision
|
|
779
797
|
}
|
|
780
798
|
changes.push({
|
|
@@ -810,7 +828,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
810
828
|
},
|
|
811
829
|
root,
|
|
812
830
|
)
|
|
813
|
-
data = completed.data
|
|
831
|
+
data = completed.data as ExecutionData
|
|
814
832
|
revision = completed.revision
|
|
815
833
|
}
|
|
816
834
|
changes.push({
|
|
@@ -833,6 +851,105 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
833
851
|
})
|
|
834
852
|
}
|
|
835
853
|
|
|
854
|
+
for (const task of (yield* tasks.list(root)).filter(
|
|
855
|
+
(task) => "review" in task.data,
|
|
856
|
+
)) {
|
|
857
|
+
if (!("review" in task.data)) continue
|
|
858
|
+
let data = task.data
|
|
859
|
+
let revision = task.revision
|
|
860
|
+
if (
|
|
861
|
+
isExpired(data.claim, now) &&
|
|
862
|
+
(data.status === "working" || data.status === "delegated")
|
|
863
|
+
) {
|
|
864
|
+
if (apply) {
|
|
865
|
+
const expired = yield* claims.expire(
|
|
866
|
+
{ taskId: task.id, revision, now },
|
|
867
|
+
root,
|
|
868
|
+
)
|
|
869
|
+
if ("review" in expired.data) data = expired.data
|
|
870
|
+
revision = expired.revision
|
|
871
|
+
}
|
|
872
|
+
changes.push({
|
|
873
|
+
kind: "release-stale-claim",
|
|
874
|
+
target: `task:${task.id}`,
|
|
875
|
+
message: `Release expired claim '${data.claim?.sessionId ?? "unknown"}'`,
|
|
876
|
+
status: apply ? "applied" : "planned",
|
|
877
|
+
})
|
|
878
|
+
}
|
|
879
|
+
const inspection = yield* worktrees.inspect(task.id, undefined, root)
|
|
880
|
+
for (const conflict of inspection.conflicts) {
|
|
881
|
+
unresolved.push({
|
|
882
|
+
kind: conflict.kind,
|
|
883
|
+
target: `task:${task.id}`,
|
|
884
|
+
message: conflict.message,
|
|
885
|
+
action: "Repair or remove the review checkout explicitly",
|
|
886
|
+
})
|
|
887
|
+
}
|
|
888
|
+
const checkout = inspection.checkouts[0]
|
|
889
|
+
if (
|
|
890
|
+
!checkout?.exists &&
|
|
891
|
+
inspection.conflicts.length === 0 &&
|
|
892
|
+
(data.status === "working" || data.status === "delegated")
|
|
893
|
+
) {
|
|
894
|
+
if (apply) yield* worktrees.materialize(task.id, undefined, root)
|
|
895
|
+
changes.push({
|
|
896
|
+
kind: "materialize-workspace",
|
|
897
|
+
target: `task:${task.id}`,
|
|
898
|
+
message: `Materialize pinned review checkout under ${inspection.codePath}`,
|
|
899
|
+
status: apply ? "applied" : "planned",
|
|
900
|
+
})
|
|
901
|
+
}
|
|
902
|
+
const repositoryPath = join(root, "repos", data.review.repo)
|
|
903
|
+
const source = yield* runExternal([
|
|
904
|
+
"git",
|
|
905
|
+
"-C",
|
|
906
|
+
repositoryPath,
|
|
907
|
+
"ls-remote",
|
|
908
|
+
"origin",
|
|
909
|
+
data.review.source.kind === "pull-request"
|
|
910
|
+
? data.review.source.fetchRef
|
|
911
|
+
: originRef(data.review.source.ref),
|
|
912
|
+
])
|
|
913
|
+
const sourceCommit = source.stdout.trim().split(/\s+/)[0] || null
|
|
914
|
+
if (!sourceCommit) {
|
|
915
|
+
warnings.push({
|
|
916
|
+
kind: "review-source-unavailable",
|
|
917
|
+
target: `task:${task.id}`,
|
|
918
|
+
message:
|
|
919
|
+
"Review source is unavailable; the pinned commit is unchanged",
|
|
920
|
+
})
|
|
921
|
+
}
|
|
922
|
+
executions.push({
|
|
923
|
+
target: `task:${task.id}`,
|
|
924
|
+
status: data.status,
|
|
925
|
+
branch: null,
|
|
926
|
+
base: null,
|
|
927
|
+
claim: data.claim ?? null,
|
|
928
|
+
checkouts: checkout
|
|
929
|
+
? [
|
|
930
|
+
{
|
|
931
|
+
repo: checkout.repo,
|
|
932
|
+
kind: "reference",
|
|
933
|
+
path: checkout.path,
|
|
934
|
+
requestedRef: data.review.commit,
|
|
935
|
+
resolvedCommit: checkout.expectedCommit,
|
|
936
|
+
registered: checkout.registered,
|
|
937
|
+
exists: checkout.exists,
|
|
938
|
+
head: checkout.actualCommit,
|
|
939
|
+
branch: checkout.actualBranch,
|
|
940
|
+
dirty: checkout.dirty,
|
|
941
|
+
},
|
|
942
|
+
]
|
|
943
|
+
: [],
|
|
944
|
+
pr: { url: null, state: "none" },
|
|
945
|
+
review: {
|
|
946
|
+
pinnedCommit: data.review.commit,
|
|
947
|
+
sourceCommit,
|
|
948
|
+
sourceAvailable: sourceCommit !== null,
|
|
949
|
+
},
|
|
950
|
+
})
|
|
951
|
+
}
|
|
952
|
+
|
|
836
953
|
return {
|
|
837
954
|
root,
|
|
838
955
|
mode: apply ? "apply" : "dry-run",
|