@markjaquith/agency 2.49.0 → 2.51.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 +56 -9
- package/cli.ts +56 -1
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +142 -38
- package/src/cli-parser.test.ts +81 -0
- package/src/cli-parser.ts +78 -1
- package/src/cli.test.ts +4 -4
- package/src/commands/init.test.ts +2 -0
- package/src/commands/review.ts +38 -0
- package/src/commands/task.ts +27 -5
- package/src/commands/vcs.test.ts +75 -0
- package/src/commands/vcs.ts +72 -0
- package/src/commands/work.test.ts +2 -0
- package/src/commands/work.ts +2 -2
- package/src/commands/worktree.ts +1 -1
- package/src/graph-schema.test.ts +1 -1
- package/src/graph-schema.ts +36 -13
- package/src/protocol.ts +1 -0
- package/src/services/ArchiveService.test.ts +2 -2
- package/src/services/ArchiveService.ts +24 -0
- package/src/services/ClaimService.ts +3 -2
- package/src/services/ContextService.ts +118 -17
- package/src/services/DoctorService.ts +58 -7
- package/src/services/GraphMutationService.ts +5 -0
- package/src/services/GraphService.ts +119 -54
- package/src/services/PhaseService.ts +196 -70
- package/src/services/PullRequestService.test.ts +2 -2
- package/src/services/PullRequestService.ts +37 -33
- package/src/services/ReadinessService.ts +7 -3
- package/src/services/RepositoryService.test.ts +31 -1
- package/src/services/RepositoryService.ts +63 -31
- package/src/services/ReviewService.test.ts +472 -0
- package/src/services/ReviewService.ts +427 -0
- package/src/services/SyncService.test.ts +69 -2
- package/src/services/SyncService.ts +161 -90
- package/src/services/TaskPhaseService.test.ts +80 -0
- package/src/services/TaskService.ts +82 -9
- package/src/services/VcsMigrationService.test.ts +211 -0
- package/src/services/VcsMigrationService.ts +816 -0
- package/src/services/VersionControlService.test.ts +100 -0
- package/src/services/VersionControlService.ts +479 -0
- package/src/services/WorkbaseService.ts +7 -2
- package/src/services/WorktreeService.test.ts +88 -17
- package/src/services/WorktreeService.ts +865 -329
- package/src/test-utils.ts +12 -0
- package/src/work-view.ts +14 -6
- package/src/workbase/AGENTS.md +2 -2
- package/src/workbase/schemas.test.ts +57 -0
- package/src/workbase/schemas.ts +57 -0
- package/src/workbase/version-control.ts +5 -0
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
RepositoryService,
|
|
26
26
|
type RepositorySetupResult,
|
|
27
27
|
} from "./RepositoryService"
|
|
28
|
+
import { VersionControlService } from "./VersionControlService"
|
|
28
29
|
|
|
29
30
|
class SyncError extends Data.TaggedError("SyncError")<{
|
|
30
31
|
readonly message: string
|
|
@@ -83,11 +84,16 @@ interface CheckoutState {
|
|
|
83
84
|
interface ExecutionSyncState {
|
|
84
85
|
readonly target: string
|
|
85
86
|
readonly status: WorkStatus
|
|
86
|
-
readonly branch: string
|
|
87
|
-
readonly base: string
|
|
87
|
+
readonly branch: string | null
|
|
88
|
+
readonly base: string | null
|
|
88
89
|
readonly claim: ClaimRecord | null
|
|
89
90
|
readonly checkouts: readonly CheckoutState[]
|
|
90
91
|
readonly pr: Record<string, unknown>
|
|
92
|
+
readonly review?: {
|
|
93
|
+
readonly pinnedCommit: string
|
|
94
|
+
readonly sourceCommit: string | null
|
|
95
|
+
readonly sourceAvailable: boolean
|
|
96
|
+
}
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
interface SyncResult {
|
|
@@ -156,7 +162,9 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
156
162
|
const worktrees = yield* WorktreeService
|
|
157
163
|
const claims = yield* ClaimService
|
|
158
164
|
const repositories = yield* RepositoryService
|
|
165
|
+
const versionControl = yield* VersionControlService
|
|
159
166
|
const { root, config } = yield* workbase.loadConfig(options.cwd)
|
|
167
|
+
const backend = yield* versionControl.forWorkbase(root)
|
|
160
168
|
const validation = yield* workbase.validate(root)
|
|
161
169
|
if (!validation.valid) {
|
|
162
170
|
return yield* new SyncError({
|
|
@@ -219,7 +227,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
219
227
|
data: phase.data,
|
|
220
228
|
})
|
|
221
229
|
}
|
|
222
|
-
} else {
|
|
230
|
+
} else if (!("review" in task.data)) {
|
|
223
231
|
records.push({
|
|
224
232
|
key: `task:${task.id}`,
|
|
225
233
|
taskId: task.id,
|
|
@@ -263,33 +271,27 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
263
271
|
workspaceConflict = true
|
|
264
272
|
continue
|
|
265
273
|
}
|
|
266
|
-
const listed = yield*
|
|
267
|
-
|
|
268
|
-
"git",
|
|
269
|
-
"-C",
|
|
270
|
-
repositoryPath,
|
|
271
|
-
"worktree",
|
|
272
|
-
"list",
|
|
273
|
-
"--porcelain",
|
|
274
|
-
"-z",
|
|
275
|
-
],
|
|
276
|
-
{ captureOutput: true },
|
|
274
|
+
const listed = yield* Effect.either(
|
|
275
|
+
backend.listWorkspaces(repositoryPath),
|
|
277
276
|
)
|
|
278
|
-
if (listed
|
|
277
|
+
if (Either.isLeft(listed)) {
|
|
279
278
|
unresolved.push({
|
|
280
279
|
kind: "worktree-inspection-failed",
|
|
281
280
|
target: record.key,
|
|
282
|
-
message:
|
|
283
|
-
listed.stderr.trim() || `Cannot inspect '${checkout.repo}'`,
|
|
281
|
+
message: `Cannot inspect '${checkout.repo}'`,
|
|
284
282
|
})
|
|
285
283
|
workspaceConflict = true
|
|
286
284
|
continue
|
|
287
285
|
}
|
|
288
286
|
const exists = yield* fs.isDirectory(checkoutPath)
|
|
289
287
|
const registered: RegisteredWorktree[] = []
|
|
290
|
-
for (const item of
|
|
288
|
+
for (const item of listed.right) {
|
|
291
289
|
registered.push({
|
|
292
|
-
|
|
290
|
+
head: item.commit,
|
|
291
|
+
branch:
|
|
292
|
+
backend.kind === "jj" && "branch" in checkout
|
|
293
|
+
? checkout.branch
|
|
294
|
+
: item.branch,
|
|
293
295
|
path: (yield* fs.exists(item.path))
|
|
294
296
|
? yield* fs.realPath(item.path)
|
|
295
297
|
: resolve(item.path),
|
|
@@ -301,39 +303,36 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
301
303
|
? join(yield* fs.realPath(codePath), checkout.repo)
|
|
302
304
|
: resolve(checkoutPath)
|
|
303
305
|
let atPath = registered.find((item) => item.path === expectedPath)
|
|
306
|
+
if (backend.kind === "jj" && atPath && exists) {
|
|
307
|
+
atPath = {
|
|
308
|
+
...atPath,
|
|
309
|
+
head: yield* backend.workspaceHead(checkoutPath),
|
|
310
|
+
}
|
|
311
|
+
}
|
|
304
312
|
const branchRef =
|
|
305
|
-
"branch" in checkout
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
313
|
+
"branch" in checkout
|
|
314
|
+
? backend.kind === "jj"
|
|
315
|
+
? checkout.branch
|
|
316
|
+
: `refs/heads/${checkout.branch}`
|
|
317
|
+
: null
|
|
318
|
+
const branchElsewhere =
|
|
319
|
+
branchRef && backend.kind !== "jj"
|
|
320
|
+
? registered.find(
|
|
321
|
+
(item) =>
|
|
322
|
+
item.branch === branchRef && item.path !== expectedPath,
|
|
323
|
+
)
|
|
324
|
+
: undefined
|
|
312
325
|
if ("branch" in checkout && !exists && !branchElsewhere) {
|
|
313
|
-
const branch = yield*
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
"-C",
|
|
317
|
-
repositoryPath,
|
|
318
|
-
"rev-parse",
|
|
319
|
-
"--verify",
|
|
320
|
-
`${checkout.branch}^{commit}`,
|
|
321
|
-
],
|
|
322
|
-
{ captureOutput: true },
|
|
326
|
+
const branch = yield* backend.resolveRevision(
|
|
327
|
+
repositoryPath,
|
|
328
|
+
checkout.branch,
|
|
323
329
|
)
|
|
324
|
-
if (branch
|
|
325
|
-
const base = yield*
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
"-C",
|
|
329
|
-
repositoryPath,
|
|
330
|
-
"rev-parse",
|
|
331
|
-
"--verify",
|
|
332
|
-
`${data.base}^{commit}`,
|
|
333
|
-
],
|
|
334
|
-
{ captureOutput: true },
|
|
330
|
+
if (!branch) {
|
|
331
|
+
const base = yield* backend.resolveRevision(
|
|
332
|
+
repositoryPath,
|
|
333
|
+
data.base,
|
|
335
334
|
)
|
|
336
|
-
if (base
|
|
335
|
+
if (!base) {
|
|
337
336
|
unresolved.push({
|
|
338
337
|
kind: "unresolved-base",
|
|
339
338
|
target: record.key,
|
|
@@ -399,22 +398,10 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
399
398
|
})
|
|
400
399
|
}
|
|
401
400
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
"git",
|
|
407
|
-
"-C",
|
|
408
|
-
repositoryPath,
|
|
409
|
-
"rev-parse",
|
|
410
|
-
"--verify",
|
|
411
|
-
`${checkout.ref}^{commit}`,
|
|
412
|
-
],
|
|
413
|
-
{ captureOutput: true },
|
|
414
|
-
)
|
|
415
|
-
if (resolvedRef?.exitCode === 0) {
|
|
416
|
-
resolvedCommit = resolvedRef.stdout.trim()
|
|
417
|
-
}
|
|
401
|
+
resolvedCommit ??= yield* backend.resolveRevision(
|
|
402
|
+
repositoryPath,
|
|
403
|
+
checkout.ref,
|
|
404
|
+
)
|
|
418
405
|
if (!resolvedCommit) {
|
|
419
406
|
unresolved.push({
|
|
420
407
|
kind: "unresolved-reference",
|
|
@@ -426,23 +413,15 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
426
413
|
}
|
|
427
414
|
}
|
|
428
415
|
|
|
429
|
-
const
|
|
416
|
+
const dirty =
|
|
430
417
|
exists && atPath
|
|
431
|
-
? yield*
|
|
432
|
-
["git", "-C", checkoutPath, "status", "--porcelain"],
|
|
433
|
-
{ captureOutput: true },
|
|
434
|
-
)
|
|
418
|
+
? yield* backend.workspaceDirty(checkoutPath)
|
|
435
419
|
: null
|
|
436
|
-
|
|
437
|
-
? dirtyResult.exitCode === 0
|
|
438
|
-
? dirtyResult.stdout.length > 0
|
|
439
|
-
: null
|
|
440
|
-
: null
|
|
441
|
-
if (dirtyResult && dirtyResult.exitCode !== 0) {
|
|
420
|
+
if (exists && atPath && dirty === null) {
|
|
442
421
|
warnings.push({
|
|
443
422
|
kind: "status-inspection-failed",
|
|
444
423
|
target: record.key,
|
|
445
|
-
message: `Could not inspect dirtiness for ${checkoutPath}
|
|
424
|
+
message: `Could not inspect dirtiness for ${checkoutPath}`,
|
|
446
425
|
action: "Inspect the checkout manually before changing it",
|
|
447
426
|
})
|
|
448
427
|
}
|
|
@@ -551,7 +530,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
551
530
|
},
|
|
552
531
|
root,
|
|
553
532
|
)
|
|
554
|
-
data = expired.data
|
|
533
|
+
data = expired.data as ExecutionData
|
|
555
534
|
revision = expired.revision
|
|
556
535
|
} else {
|
|
557
536
|
const claim: ClaimRecord = {
|
|
@@ -602,15 +581,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
602
581
|
let prConflict = false
|
|
603
582
|
const repositoryPath = join(root, "repos", data.repo)
|
|
604
583
|
const remoteName = config.delivery?.remote ?? "origin"
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
"-C",
|
|
608
|
-
repositoryPath,
|
|
609
|
-
"remote",
|
|
610
|
-
"get-url",
|
|
611
|
-
remoteName,
|
|
612
|
-
])
|
|
613
|
-
const remoteRepository = remote.stdout
|
|
584
|
+
const remoteUrl = yield* backend.remoteUrl(repositoryPath, remoteName)
|
|
585
|
+
const remoteRepository = (remoteUrl ?? "")
|
|
614
586
|
.trim()
|
|
615
587
|
.replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
|
|
616
588
|
.replace(/^[^:]+:/, "")
|
|
@@ -630,11 +602,11 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
630
602
|
})
|
|
631
603
|
}
|
|
632
604
|
|
|
633
|
-
if (config.delivery &&
|
|
605
|
+
if (config.delivery && !remoteUrl) {
|
|
634
606
|
warnings.push({
|
|
635
607
|
kind: "delivery-remote-unavailable",
|
|
636
608
|
target: record.key,
|
|
637
|
-
message: `Could not inspect delivery remote '${remoteName}'
|
|
609
|
+
message: `Could not inspect delivery remote '${remoteName}'`,
|
|
638
610
|
})
|
|
639
611
|
} else if (config.delivery) {
|
|
640
612
|
const resolved = resolveDeliveryCommand(config.delivery, "query", {
|
|
@@ -787,7 +759,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
787
759
|
},
|
|
788
760
|
root,
|
|
789
761
|
)
|
|
790
|
-
data = recorded.data
|
|
762
|
+
data = recorded.data as ExecutionData
|
|
791
763
|
revision = recorded.revision
|
|
792
764
|
}
|
|
793
765
|
changes.push({
|
|
@@ -823,7 +795,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
823
795
|
},
|
|
824
796
|
root,
|
|
825
797
|
)
|
|
826
|
-
data = completed.data
|
|
798
|
+
data = completed.data as ExecutionData
|
|
827
799
|
revision = completed.revision
|
|
828
800
|
}
|
|
829
801
|
changes.push({
|
|
@@ -846,6 +818,105 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
846
818
|
})
|
|
847
819
|
}
|
|
848
820
|
|
|
821
|
+
for (const task of (yield* tasks.list(root)).filter(
|
|
822
|
+
(task) => "review" in task.data,
|
|
823
|
+
)) {
|
|
824
|
+
if (!("review" in task.data)) continue
|
|
825
|
+
let data = task.data
|
|
826
|
+
let revision = task.revision
|
|
827
|
+
if (
|
|
828
|
+
isExpired(data.claim, now) &&
|
|
829
|
+
(data.status === "working" || data.status === "delegated")
|
|
830
|
+
) {
|
|
831
|
+
if (apply) {
|
|
832
|
+
const expired = yield* claims.expire(
|
|
833
|
+
{ taskId: task.id, revision, now },
|
|
834
|
+
root,
|
|
835
|
+
)
|
|
836
|
+
if ("review" in expired.data) data = expired.data
|
|
837
|
+
revision = expired.revision
|
|
838
|
+
}
|
|
839
|
+
changes.push({
|
|
840
|
+
kind: "release-stale-claim",
|
|
841
|
+
target: `task:${task.id}`,
|
|
842
|
+
message: `Release expired claim '${data.claim?.sessionId ?? "unknown"}'`,
|
|
843
|
+
status: apply ? "applied" : "planned",
|
|
844
|
+
})
|
|
845
|
+
}
|
|
846
|
+
const inspection = yield* worktrees.inspect(task.id, undefined, root)
|
|
847
|
+
for (const conflict of inspection.conflicts) {
|
|
848
|
+
unresolved.push({
|
|
849
|
+
kind: conflict.kind,
|
|
850
|
+
target: `task:${task.id}`,
|
|
851
|
+
message: conflict.message,
|
|
852
|
+
action: "Repair or remove the review checkout explicitly",
|
|
853
|
+
})
|
|
854
|
+
}
|
|
855
|
+
const checkout = inspection.checkouts[0]
|
|
856
|
+
if (
|
|
857
|
+
!checkout?.exists &&
|
|
858
|
+
inspection.conflicts.length === 0 &&
|
|
859
|
+
(data.status === "working" || data.status === "delegated")
|
|
860
|
+
) {
|
|
861
|
+
if (apply) yield* worktrees.materialize(task.id, undefined, root)
|
|
862
|
+
changes.push({
|
|
863
|
+
kind: "materialize-workspace",
|
|
864
|
+
target: `task:${task.id}`,
|
|
865
|
+
message: `Materialize pinned review checkout under ${inspection.codePath}`,
|
|
866
|
+
status: apply ? "applied" : "planned",
|
|
867
|
+
})
|
|
868
|
+
}
|
|
869
|
+
const repositoryPath = join(root, "repos", data.review.repo)
|
|
870
|
+
const source = yield* runExternal([
|
|
871
|
+
"git",
|
|
872
|
+
"-C",
|
|
873
|
+
repositoryPath,
|
|
874
|
+
"ls-remote",
|
|
875
|
+
"origin",
|
|
876
|
+
data.review.source.kind === "pull-request"
|
|
877
|
+
? data.review.source.fetchRef
|
|
878
|
+
: originRef(data.review.source.ref),
|
|
879
|
+
])
|
|
880
|
+
const sourceCommit = source.stdout.trim().split(/\s+/)[0] || null
|
|
881
|
+
if (!sourceCommit) {
|
|
882
|
+
warnings.push({
|
|
883
|
+
kind: "review-source-unavailable",
|
|
884
|
+
target: `task:${task.id}`,
|
|
885
|
+
message:
|
|
886
|
+
"Review source is unavailable; the pinned commit is unchanged",
|
|
887
|
+
})
|
|
888
|
+
}
|
|
889
|
+
executions.push({
|
|
890
|
+
target: `task:${task.id}`,
|
|
891
|
+
status: data.status,
|
|
892
|
+
branch: null,
|
|
893
|
+
base: null,
|
|
894
|
+
claim: data.claim ?? null,
|
|
895
|
+
checkouts: checkout
|
|
896
|
+
? [
|
|
897
|
+
{
|
|
898
|
+
repo: checkout.repo,
|
|
899
|
+
kind: "reference",
|
|
900
|
+
path: checkout.path,
|
|
901
|
+
requestedRef: data.review.commit,
|
|
902
|
+
resolvedCommit: checkout.expectedCommit,
|
|
903
|
+
registered: checkout.registered,
|
|
904
|
+
exists: checkout.exists,
|
|
905
|
+
head: checkout.actualCommit,
|
|
906
|
+
branch: checkout.actualBranch,
|
|
907
|
+
dirty: checkout.dirty,
|
|
908
|
+
},
|
|
909
|
+
]
|
|
910
|
+
: [],
|
|
911
|
+
pr: { url: null, state: "none" },
|
|
912
|
+
review: {
|
|
913
|
+
pinnedCommit: data.review.commit,
|
|
914
|
+
sourceCommit,
|
|
915
|
+
sourceAvailable: sourceCommit !== null,
|
|
916
|
+
},
|
|
917
|
+
})
|
|
918
|
+
}
|
|
919
|
+
|
|
849
920
|
return {
|
|
850
921
|
root,
|
|
851
922
|
mode: apply ? "apply" : "dry-run",
|
|
@@ -7,6 +7,13 @@ import { EpicService } from "./EpicService"
|
|
|
7
7
|
import { TaskService } from "./TaskService"
|
|
8
8
|
import { PhaseService } from "./PhaseService"
|
|
9
9
|
import { PullRequestService } from "./PullRequestService"
|
|
10
|
+
import { WorktreeService } from "./WorktreeService"
|
|
11
|
+
|
|
12
|
+
const run = async (args: string[], cwd?: string) => {
|
|
13
|
+
const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" })
|
|
14
|
+
const exitCode = await child.exited
|
|
15
|
+
if (exitCode !== 0) throw new Error(await new Response(child.stderr).text())
|
|
16
|
+
}
|
|
10
17
|
|
|
11
18
|
describe("task and phase services", () => {
|
|
12
19
|
let root: string
|
|
@@ -283,6 +290,79 @@ describe("task and phase services", () => {
|
|
|
283
290
|
})
|
|
284
291
|
})
|
|
285
292
|
|
|
293
|
+
test("recreates jj workspaces when converting a task to phases", async () => {
|
|
294
|
+
if (!Bun.which("jj")) return
|
|
295
|
+
const source = join(root, "source")
|
|
296
|
+
const repository = join(root, "repos/agency")
|
|
297
|
+
await rm(repository, { recursive: true, force: true })
|
|
298
|
+
await mkdir(source)
|
|
299
|
+
await run(["git", "init", "--initial-branch=main"], source)
|
|
300
|
+
await run(["git", "config", "user.email", "test@example.com"], source)
|
|
301
|
+
await run(["git", "config", "user.name", "Test"], source)
|
|
302
|
+
await Bun.write(join(source, "README.md"), "example\n")
|
|
303
|
+
await run(["git", "add", "README.md"], source)
|
|
304
|
+
await run(["git", "commit", "-m", "initial"], source)
|
|
305
|
+
await run(["git", "clone", source, repository])
|
|
306
|
+
await run(["jj", "git", "init", "--colocate", repository])
|
|
307
|
+
await Bun.write(
|
|
308
|
+
join(root, "agency.json"),
|
|
309
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
310
|
+
)
|
|
311
|
+
await runTestEffect(
|
|
312
|
+
TaskService.pipe(
|
|
313
|
+
Effect.flatMap((service) =>
|
|
314
|
+
service.create(
|
|
315
|
+
{
|
|
316
|
+
id: "jj-single",
|
|
317
|
+
ticketUrl: null,
|
|
318
|
+
repo: "agency",
|
|
319
|
+
branch: "task/jj-single",
|
|
320
|
+
base: "main",
|
|
321
|
+
},
|
|
322
|
+
root,
|
|
323
|
+
),
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
const workspace = await runTestEffect(
|
|
328
|
+
WorktreeService.pipe(
|
|
329
|
+
Effect.flatMap((service) =>
|
|
330
|
+
service.materialize("jj-single", undefined, root),
|
|
331
|
+
),
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
await runTestEffect(
|
|
336
|
+
PhaseService.pipe(
|
|
337
|
+
Effect.flatMap((service) =>
|
|
338
|
+
service.create(
|
|
339
|
+
{
|
|
340
|
+
taskId: "jj-single",
|
|
341
|
+
id: "extra",
|
|
342
|
+
firstPhase: "implementation",
|
|
343
|
+
repo: "agency",
|
|
344
|
+
branch: "task/extra",
|
|
345
|
+
base: "main",
|
|
346
|
+
},
|
|
347
|
+
root,
|
|
348
|
+
),
|
|
349
|
+
),
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
const moved = join(
|
|
354
|
+
root,
|
|
355
|
+
"tasks/jj-single/phases/implementation/code/agency",
|
|
356
|
+
)
|
|
357
|
+
expect(await Bun.file(workspace.codePath).exists()).toBe(false)
|
|
358
|
+
expect(await Bun.file(join(moved, "README.md")).text()).toBe("example\n")
|
|
359
|
+
const listed = Bun.spawnSync(
|
|
360
|
+
["jj", "-R", repository, "workspace", "list", "-T", 'root ++ "\\n"'],
|
|
361
|
+
{ stdout: "pipe" },
|
|
362
|
+
)
|
|
363
|
+
expect(new TextDecoder().decode(listed.stdout)).toContain(moved)
|
|
364
|
+
})
|
|
365
|
+
|
|
286
366
|
test("preserves non-PR completion when converting a task to phases", async () => {
|
|
287
367
|
await runTestEffect(
|
|
288
368
|
TaskService.pipe(
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
EntityId,
|
|
9
9
|
TaskFrontmatter,
|
|
10
10
|
type RepositoryReference,
|
|
11
|
+
type ReviewRecord,
|
|
11
12
|
type TaskFrontmatter as TaskData,
|
|
12
13
|
WorkStatus,
|
|
13
14
|
} from "../workbase/schemas"
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
import {
|
|
27
28
|
documentWriteStep,
|
|
28
29
|
runLifecycleTransaction,
|
|
30
|
+
type TransactionStep,
|
|
29
31
|
} from "./LifecycleTransaction"
|
|
30
32
|
|
|
31
33
|
class TaskError extends Data.TaggedError("TaskError")<{
|
|
@@ -50,6 +52,7 @@ export interface CreateTaskInput {
|
|
|
50
52
|
readonly repos?: readonly RepositoryReference[]
|
|
51
53
|
readonly branch?: string
|
|
52
54
|
readonly base?: string
|
|
55
|
+
readonly review?: ReviewRecord
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
const decodeTask = (input: unknown) => {
|
|
@@ -78,6 +81,50 @@ const decodeStatus = (status: string) => {
|
|
|
78
81
|
: Effect.succeed(result.right)
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
const reviewPinRef = (taskId: string) =>
|
|
85
|
+
`refs/agency/reviews/${Buffer.from(taskId).toString("hex")}`
|
|
86
|
+
|
|
87
|
+
const reviewPinStep = (
|
|
88
|
+
root: string,
|
|
89
|
+
taskId: string,
|
|
90
|
+
review: ReviewRecord,
|
|
91
|
+
): TransactionStep => {
|
|
92
|
+
const repositoryPath = join(root, "repos", review.repo)
|
|
93
|
+
const ref = reviewPinRef(taskId)
|
|
94
|
+
const run = async (args: readonly string[]) => {
|
|
95
|
+
const child = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
|
|
96
|
+
const [exitCode, stderr] = await Promise.all([
|
|
97
|
+
child.exited,
|
|
98
|
+
new Response(child.stderr).text(),
|
|
99
|
+
])
|
|
100
|
+
if (exitCode !== 0) throw new Error(stderr.trim() || args.join(" "))
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
label: `retain review pin for ${taskId}`,
|
|
104
|
+
apply: () =>
|
|
105
|
+
run([
|
|
106
|
+
"git",
|
|
107
|
+
"-C",
|
|
108
|
+
repositoryPath,
|
|
109
|
+
"update-ref",
|
|
110
|
+
ref,
|
|
111
|
+
review.commit,
|
|
112
|
+
"0".repeat(40),
|
|
113
|
+
]),
|
|
114
|
+
rollback: () =>
|
|
115
|
+
run([
|
|
116
|
+
"git",
|
|
117
|
+
"-C",
|
|
118
|
+
repositoryPath,
|
|
119
|
+
"update-ref",
|
|
120
|
+
"-d",
|
|
121
|
+
ref,
|
|
122
|
+
review.commit,
|
|
123
|
+
]),
|
|
124
|
+
manualRecovery: `Delete ${ref} from repository '${review.repo}'`,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
81
128
|
export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
82
129
|
sync: () => ({
|
|
83
130
|
create: (input: CreateTaskInput, startPath: string = process.cwd()) =>
|
|
@@ -102,7 +149,28 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
102
149
|
}
|
|
103
150
|
|
|
104
151
|
let data: TaskData
|
|
105
|
-
if (input.
|
|
152
|
+
if (input.review) {
|
|
153
|
+
if (
|
|
154
|
+
input.multiPhase ||
|
|
155
|
+
input.repo !== undefined ||
|
|
156
|
+
input.repos !== undefined ||
|
|
157
|
+
input.branch !== undefined ||
|
|
158
|
+
input.base !== undefined
|
|
159
|
+
) {
|
|
160
|
+
return yield* new TaskError({
|
|
161
|
+
message:
|
|
162
|
+
"Review tasks cannot include writable or multi-phase fields",
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
data = yield* decodeTask({
|
|
166
|
+
ticketUrl: input.ticketUrl,
|
|
167
|
+
...(input.description !== undefined
|
|
168
|
+
? { description: input.description }
|
|
169
|
+
: {}),
|
|
170
|
+
...(input.epic ? { epic: input.epic } : {}),
|
|
171
|
+
review: input.review,
|
|
172
|
+
})
|
|
173
|
+
} else if (input.multiPhase) {
|
|
106
174
|
data = yield* decodeTask({
|
|
107
175
|
ticketUrl: input.ticketUrl,
|
|
108
176
|
...(input.description !== undefined
|
|
@@ -132,12 +200,14 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
132
200
|
}
|
|
133
201
|
|
|
134
202
|
const referencedRepos =
|
|
135
|
-
"
|
|
136
|
-
? [
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
203
|
+
"review" in data
|
|
204
|
+
? [data.review.repo]
|
|
205
|
+
: "repo" in data
|
|
206
|
+
? [
|
|
207
|
+
data.repo,
|
|
208
|
+
...(data.repos ?? []).map((reference) => reference.repo),
|
|
209
|
+
]
|
|
210
|
+
: []
|
|
141
211
|
if (new Set(referencedRepos).size !== referencedRepos.length) {
|
|
142
212
|
return yield* new TaskError({
|
|
143
213
|
message:
|
|
@@ -192,7 +262,10 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
192
262
|
preconditions: parentEpic
|
|
193
263
|
? [{ path: parentEpic.path, revision: parentEpic.revision }]
|
|
194
264
|
: [],
|
|
195
|
-
steps: [
|
|
265
|
+
steps: [
|
|
266
|
+
...(input.review ? [reviewPinStep(root, id, input.review)] : []),
|
|
267
|
+
documentWriteStep(root, writes),
|
|
268
|
+
],
|
|
196
269
|
})
|
|
197
270
|
|
|
198
271
|
return {
|
|
@@ -279,7 +352,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
279
352
|
message: "Non-PR completion is valid only with a done status",
|
|
280
353
|
})
|
|
281
354
|
}
|
|
282
|
-
if (nonPrCompletion && record.data.pr !== null) {
|
|
355
|
+
if (nonPrCompletion && "pr" in record.data && record.data.pr !== null) {
|
|
283
356
|
return yield* new TaskError({
|
|
284
357
|
message:
|
|
285
358
|
"Cannot complete without a pull request while an authoritative pull request is recorded",
|