@markjaquith/agency 2.49.0 → 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 +22 -1
- package/cli.ts +23 -0
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +141 -38
- package/src/cli-parser.test.ts +62 -0
- package/src/cli-parser.ts +55 -1
- package/src/commands/review.ts +38 -0
- package/src/commands/task.ts +27 -5
- 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.ts +3 -2
- package/src/services/ContextService.ts +56 -4
- package/src/services/DoctorService.ts +45 -1
- package/src/services/GraphMutationService.ts +5 -0
- package/src/services/GraphService.ts +118 -54
- package/src/services/PhaseService.ts +5 -0
- package/src/services/PullRequestService.test.ts +2 -2
- package/src/services/PullRequestService.ts +23 -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 +2 -2
- package/src/services/SyncService.ts +110 -6
- package/src/services/TaskService.ts +82 -9
- package/src/services/WorkbaseService.ts +2 -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/schemas.test.ts +57 -0
- package/src/workbase/schemas.ts +56 -0
package/src/graph-schema.ts
CHANGED
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
EpicFrontmatter,
|
|
4
4
|
PhaseFrontmatter,
|
|
5
5
|
PullRequestRecord,
|
|
6
|
+
ClaimRecord,
|
|
7
|
+
ReviewRecord,
|
|
6
8
|
TaskFrontmatter,
|
|
7
9
|
WorkStatus,
|
|
8
10
|
} from "./workbase/schemas"
|
|
@@ -69,13 +71,24 @@ const DocumentHash = Schema.Struct({ sha256: Schema.String })
|
|
|
69
71
|
export const GraphEpicData = Schema.extend(EpicFrontmatter, DocumentHash)
|
|
70
72
|
export const GraphTaskData = Schema.extend(TaskFrontmatter, DocumentHash)
|
|
71
73
|
export const GraphPhaseData = Schema.extend(PhaseFrontmatter, DocumentHash)
|
|
72
|
-
export const GraphExecutionData = Schema.
|
|
73
|
-
|
|
74
|
+
export const GraphExecutionData = Schema.Union(
|
|
75
|
+
Schema.extend(
|
|
76
|
+
PhaseFrontmatter,
|
|
77
|
+
Schema.Struct({
|
|
78
|
+
taskId: Schema.String,
|
|
79
|
+
phaseId: Schema.optional(Schema.String),
|
|
80
|
+
ticketUrl: Schema.optional(Schema.NullOr(Schema.String)),
|
|
81
|
+
epic: Schema.optional(Schema.String),
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
74
84
|
Schema.Struct({
|
|
75
85
|
taskId: Schema.String,
|
|
76
|
-
|
|
77
|
-
|
|
86
|
+
ticketUrl: Schema.NullOr(Schema.String),
|
|
87
|
+
description: Schema.optional(Schema.String),
|
|
78
88
|
epic: Schema.optional(Schema.String),
|
|
89
|
+
review: ReviewRecord,
|
|
90
|
+
status: WorkStatus,
|
|
91
|
+
claim: Schema.optional(ClaimRecord),
|
|
79
92
|
}),
|
|
80
93
|
)
|
|
81
94
|
|
|
@@ -98,15 +111,24 @@ export const GraphRepositoryGit = Schema.Struct({
|
|
|
98
111
|
head: Schema.NullOr(Schema.String),
|
|
99
112
|
branch: Schema.NullOr(Schema.String),
|
|
100
113
|
})
|
|
101
|
-
export const GraphExecutionGit = Schema.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
export const GraphExecutionGit = Schema.Union(
|
|
115
|
+
Schema.Struct({
|
|
116
|
+
branch: Schema.String,
|
|
117
|
+
base: Schema.String,
|
|
118
|
+
branchCommit: Schema.NullOr(Schema.String),
|
|
119
|
+
baseCommit: Schema.NullOr(Schema.String),
|
|
120
|
+
checkoutCommit: Schema.NullOr(Schema.String),
|
|
121
|
+
checkoutBranch: Schema.NullOr(Schema.String),
|
|
122
|
+
dirty: Schema.NullOr(Schema.Boolean),
|
|
123
|
+
}),
|
|
124
|
+
Schema.Struct({
|
|
125
|
+
pinnedCommit: Schema.String,
|
|
126
|
+
sourceCommit: Schema.NullOr(Schema.String),
|
|
127
|
+
checkoutCommit: Schema.NullOr(Schema.String),
|
|
128
|
+
checkoutBranch: Schema.NullOr(Schema.String),
|
|
129
|
+
dirty: Schema.NullOr(Schema.Boolean),
|
|
130
|
+
}),
|
|
131
|
+
)
|
|
110
132
|
export const GraphPr = Schema.Union(
|
|
111
133
|
Schema.Struct({ url: Schema.Null, state: Schema.Literal("none") }),
|
|
112
134
|
Schema.Struct({ url: Schema.String, state: Schema.Literal("unavailable") }),
|
package/src/protocol.ts
CHANGED
|
@@ -100,6 +100,7 @@ const errorMetadata: Readonly<Record<string, ErrorMetadata>> = {
|
|
|
100
100
|
ArchiveError: { code: "ARCHIVE_ERROR", retryable: false },
|
|
101
101
|
WorktreeError: { code: "WORKTREE_ERROR", retryable: false },
|
|
102
102
|
PullRequestError: { code: "PULL_REQUEST_ERROR", retryable: false },
|
|
103
|
+
ReviewError: { code: "REVIEW_ERROR", retryable: false },
|
|
103
104
|
ContextError: {
|
|
104
105
|
code: "CONTEXT_ERROR",
|
|
105
106
|
retryable: false,
|
|
@@ -315,7 +315,7 @@ describe("ArchiveService", () => {
|
|
|
315
315
|
),
|
|
316
316
|
),
|
|
317
317
|
)
|
|
318
|
-
await Bun.write(join(workspace.writablePath
|
|
318
|
+
await Bun.write(join(workspace.writablePath!, "dirty.txt"), "keep me\n")
|
|
319
319
|
|
|
320
320
|
await expect(
|
|
321
321
|
runTestEffect(
|
|
@@ -747,7 +747,7 @@ describe("ArchiveService", () => {
|
|
|
747
747
|
),
|
|
748
748
|
).rejects.toThrow("Another archive or restore operation")
|
|
749
749
|
expect(
|
|
750
|
-
await Bun.file(join(workspace.writablePath
|
|
750
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
|
|
751
751
|
).toBe(true)
|
|
752
752
|
expect(await Bun.file(join(root, "tasks/locked/TASK.md")).exists()).toBe(
|
|
753
753
|
true,
|
|
@@ -108,7 +108,8 @@ interface ReconcileInput {
|
|
|
108
108
|
const PR_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/
|
|
109
109
|
|
|
110
110
|
type SingleTaskData = Extract<TaskData, { readonly repo: string }>
|
|
111
|
-
type
|
|
111
|
+
type ReviewTaskData = Extract<TaskData, { readonly review: unknown }>
|
|
112
|
+
type ExecutionData = SingleTaskData | ReviewTaskData | PhaseData
|
|
112
113
|
|
|
113
114
|
const isTaggedClaimError = (
|
|
114
115
|
error: unknown,
|
|
@@ -541,7 +542,7 @@ export class ClaimService extends Effect.Service<ClaimService>()(
|
|
|
541
542
|
message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
|
|
542
543
|
})
|
|
543
544
|
}
|
|
544
|
-
if (input.nonPrCompletion && data.pr !== null) {
|
|
545
|
+
if (input.nonPrCompletion && "pr" in data && data.pr !== null) {
|
|
545
546
|
throw new ClaimError({
|
|
546
547
|
target: inspected.target.label,
|
|
547
548
|
message:
|
|
@@ -54,6 +54,7 @@ interface CheckoutInspection {
|
|
|
54
54
|
readonly checkoutCommit: string | null
|
|
55
55
|
readonly checkoutBranch: string | null
|
|
56
56
|
readonly detached: boolean | null
|
|
57
|
+
readonly dirty: boolean | null
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
type ExecutionData = PhaseData & Partial<Pick<TaskData, "ticketUrl" | "epic">>
|
|
@@ -92,6 +93,18 @@ const runGit = (fs: FileSystemService, cwd: string, args: readonly string[]) =>
|
|
|
92
93
|
Effect.catchAll(() => Effect.succeed(null)),
|
|
93
94
|
)
|
|
94
95
|
|
|
96
|
+
const runGitText = (
|
|
97
|
+
fs: FileSystemService,
|
|
98
|
+
cwd: string,
|
|
99
|
+
args: readonly string[],
|
|
100
|
+
) =>
|
|
101
|
+
fs.runCommand(["git", "-C", cwd, ...args], { captureOutput: true }).pipe(
|
|
102
|
+
Effect.map((result) =>
|
|
103
|
+
result.exitCode === 0 ? result.stdout.trim() : null,
|
|
104
|
+
),
|
|
105
|
+
Effect.catchAll(() => Effect.succeed(null)),
|
|
106
|
+
)
|
|
107
|
+
|
|
95
108
|
const worktreePaths = (output: string | null) =>
|
|
96
109
|
new Set(
|
|
97
110
|
(output ?? "")
|
|
@@ -765,9 +778,13 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
765
778
|
: task?.data && "repo" in task.data
|
|
766
779
|
? (task.data as ExecutionData)
|
|
767
780
|
: null
|
|
768
|
-
const
|
|
769
|
-
?
|
|
770
|
-
|
|
781
|
+
const reviewData =
|
|
782
|
+
task?.data && "review" in task.data ? task.data.review : null
|
|
783
|
+
const references: readonly RepositoryReference[] = reviewData
|
|
784
|
+
? [{ repo: reviewData.repo, ref: reviewData.commit }]
|
|
785
|
+
: executionData
|
|
786
|
+
? (executionData.repos ?? [])
|
|
787
|
+
: (epic?.data.repos ?? [])
|
|
771
788
|
const entityDirectory = target.path.replace(
|
|
772
789
|
/\/(?:EPIC|TASK|PHASE)\.md$/,
|
|
773
790
|
"",
|
|
@@ -812,6 +829,7 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
812
829
|
checkoutCommit: null,
|
|
813
830
|
checkoutBranch: null,
|
|
814
831
|
detached: null,
|
|
832
|
+
dirty: null,
|
|
815
833
|
}
|
|
816
834
|
}
|
|
817
835
|
const checkoutCommit = yield* runGit(fs, checkoutPath, [
|
|
@@ -824,6 +842,10 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
824
842
|
"--short",
|
|
825
843
|
"HEAD",
|
|
826
844
|
])
|
|
845
|
+
const checkoutStatus = yield* runGitText(fs, checkoutPath, [
|
|
846
|
+
"status",
|
|
847
|
+
"--porcelain",
|
|
848
|
+
])
|
|
827
849
|
const resolvedCheckoutPath = yield* fs.realPath(checkoutPath)
|
|
828
850
|
registered = registered || listedPaths.has(resolvedCheckoutPath)
|
|
829
851
|
if (checkoutCommit === null) {
|
|
@@ -837,6 +859,8 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
837
859
|
checkoutCommit,
|
|
838
860
|
checkoutBranch,
|
|
839
861
|
detached: checkoutBranch === null,
|
|
862
|
+
dirty:
|
|
863
|
+
checkoutStatus === null ? null : checkoutStatus.length > 0,
|
|
840
864
|
}
|
|
841
865
|
})
|
|
842
866
|
|
|
@@ -901,6 +925,17 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
901
925
|
...(yield* inspectCheckout(repositoryPath, checkoutPath)),
|
|
902
926
|
})
|
|
903
927
|
}
|
|
928
|
+
const reviewSourceCommit = reviewData
|
|
929
|
+
? yield* runGit(fs, join(root, "repos", reviewData.repo), [
|
|
930
|
+
"ls-remote",
|
|
931
|
+
"origin",
|
|
932
|
+
reviewData.source.kind === "pull-request"
|
|
933
|
+
? reviewData.source.fetchRef
|
|
934
|
+
: reviewData.source.ref
|
|
935
|
+
.replace(/^refs\/remotes\/origin\//, "")
|
|
936
|
+
.replace(/^origin\//, ""),
|
|
937
|
+
])
|
|
938
|
+
: null
|
|
904
939
|
|
|
905
940
|
const checkoutStates = [writable, ...referenceCheckouts].filter(
|
|
906
941
|
(value): value is NonNullable<typeof value> => value !== null,
|
|
@@ -956,7 +991,11 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
956
991
|
aggregate: aggregateProgress(aggregateStatuses),
|
|
957
992
|
},
|
|
958
993
|
authority: {
|
|
959
|
-
mode:
|
|
994
|
+
mode: reviewData
|
|
995
|
+
? "review"
|
|
996
|
+
: executionData
|
|
997
|
+
? "execution"
|
|
998
|
+
: "orchestration",
|
|
960
999
|
writable: writable
|
|
961
1000
|
? {
|
|
962
1001
|
repo: writable.repo,
|
|
@@ -972,6 +1011,9 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
972
1011
|
repositoryPath: reference.repositoryPath,
|
|
973
1012
|
checkoutPath: reference.checkoutPath,
|
|
974
1013
|
})),
|
|
1014
|
+
documents: {
|
|
1015
|
+
writable: reviewData && task ? [task.path] : [],
|
|
1016
|
+
},
|
|
975
1017
|
},
|
|
976
1018
|
workspace: options.compact
|
|
977
1019
|
? {
|
|
@@ -997,6 +1039,16 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
997
1039
|
references: referenceCheckouts,
|
|
998
1040
|
warnings: inspectionWarnings,
|
|
999
1041
|
},
|
|
1042
|
+
review: reviewData
|
|
1043
|
+
? {
|
|
1044
|
+
...reviewData,
|
|
1045
|
+
sourceObservation: {
|
|
1046
|
+
available: reviewSourceCommit !== null,
|
|
1047
|
+
commit: reviewSourceCommit?.split(/\s+/)[0] ?? null,
|
|
1048
|
+
},
|
|
1049
|
+
checkout: referenceCheckouts[0] ?? null,
|
|
1050
|
+
}
|
|
1051
|
+
: null,
|
|
1000
1052
|
pr: executionData?.pr
|
|
1001
1053
|
? normalizePullRequestRecord(executionData.pr)
|
|
1002
1054
|
: { url: null, state: "none" },
|
|
@@ -282,6 +282,8 @@ export class DoctorService extends Effect.Service<DoctorService>()(
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
const refs = new Map<string, Set<string>>()
|
|
285
|
+
const reviewSources: { repo: string; task: string; ref: string }[] =
|
|
286
|
+
[]
|
|
285
287
|
const declareRef = (repo: string, ref: string) => {
|
|
286
288
|
const values = refs.get(repo) ?? new Set<string>()
|
|
287
289
|
values.add(ref)
|
|
@@ -293,7 +295,19 @@ export class DoctorService extends Effect.Service<DoctorService>()(
|
|
|
293
295
|
declareRef(reference.repo, reference.ref)
|
|
294
296
|
}
|
|
295
297
|
for (const task of yield* tasks.list(root)) {
|
|
296
|
-
if ("
|
|
298
|
+
if ("review" in task.data) {
|
|
299
|
+
declareRef(task.data.review.repo, task.data.review.commit)
|
|
300
|
+
reviewSources.push({
|
|
301
|
+
repo: task.data.review.repo,
|
|
302
|
+
task: task.id,
|
|
303
|
+
ref:
|
|
304
|
+
task.data.review.source.kind === "pull-request"
|
|
305
|
+
? task.data.review.source.fetchRef
|
|
306
|
+
: task.data.review.source.ref
|
|
307
|
+
.replace(/^refs\/remotes\/origin\//, "")
|
|
308
|
+
.replace(/^origin\//, ""),
|
|
309
|
+
})
|
|
310
|
+
} else if ("repo" in task.data) {
|
|
297
311
|
declareRef(task.data.repo, task.data.base)
|
|
298
312
|
for (const reference of task.data.repos ?? [])
|
|
299
313
|
declareRef(reference.repo, reference.ref)
|
|
@@ -357,6 +371,36 @@ export class DoctorService extends Effect.Service<DoctorService>()(
|
|
|
357
371
|
})
|
|
358
372
|
if (!repositoryValid) continue
|
|
359
373
|
|
|
374
|
+
for (const source of reviewSources.filter(
|
|
375
|
+
(item) => item.repo === repository.alias,
|
|
376
|
+
)) {
|
|
377
|
+
const observed = yield* fs.runCommand(
|
|
378
|
+
[
|
|
379
|
+
"git",
|
|
380
|
+
"-C",
|
|
381
|
+
repository.path,
|
|
382
|
+
"ls-remote",
|
|
383
|
+
"origin",
|
|
384
|
+
source.ref,
|
|
385
|
+
],
|
|
386
|
+
{ captureOutput: true },
|
|
387
|
+
)
|
|
388
|
+
const available =
|
|
389
|
+
observed.exitCode === 0 && Boolean(observed.stdout.trim())
|
|
390
|
+
add({
|
|
391
|
+
id: `review.${source.task}.source`,
|
|
392
|
+
category: "repository",
|
|
393
|
+
level: "warning",
|
|
394
|
+
status: available ? "pass" : "fail",
|
|
395
|
+
message: available
|
|
396
|
+
? `Review source '${source.ref}' is available for task '${source.task}'`
|
|
397
|
+
: `Review source '${source.ref}' is unavailable for task '${source.task}'; its pin remains usable`,
|
|
398
|
+
remediation: available
|
|
399
|
+
? undefined
|
|
400
|
+
: "Use the pinned checkout or refresh after restoring the source.",
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
|
|
360
404
|
for (const ref of [...(refs.get(repository.alias) ?? [])].sort()) {
|
|
361
405
|
const local = yield* fs.runCommand(
|
|
362
406
|
[
|
|
@@ -360,6 +360,11 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
|
|
|
360
360
|
"base",
|
|
361
361
|
"pr",
|
|
362
362
|
].some((key) => updates[key as keyof TaskUpdates] !== undefined)
|
|
363
|
+
if ("review" in record.data && executionChange) {
|
|
364
|
+
return yield* new GraphMutationError({
|
|
365
|
+
message: `Review task '${id}' does not have writable execution metadata`,
|
|
366
|
+
})
|
|
367
|
+
}
|
|
363
368
|
if ("phases" in record.data && executionChange) {
|
|
364
369
|
return yield* new GraphMutationError({
|
|
365
370
|
message: `Task '${id}' has multiple phases; update execution metadata on a phase instead`,
|
|
@@ -54,7 +54,10 @@ interface RepositoryRecord {
|
|
|
54
54
|
readonly target: string | null
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
type ExecutionData =
|
|
57
|
+
type ExecutionData =
|
|
58
|
+
| PhaseData
|
|
59
|
+
| Extract<TaskData, { readonly repo: string }>
|
|
60
|
+
| Extract<TaskData, { readonly review: unknown }>
|
|
58
61
|
|
|
59
62
|
export interface GraphOptions {
|
|
60
63
|
readonly cwd?: string
|
|
@@ -548,10 +551,18 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
548
551
|
const executionRepositories = (
|
|
549
552
|
data: TaskData | PhaseData,
|
|
550
553
|
): readonly string[] =>
|
|
551
|
-
"
|
|
552
|
-
? [data.
|
|
553
|
-
:
|
|
554
|
+
"review" in data
|
|
555
|
+
? [data.review.repo]
|
|
556
|
+
: "repo" in data
|
|
557
|
+
? [data.repo, ...(data.repos ?? []).map((item) => item.repo)]
|
|
558
|
+
: []
|
|
554
559
|
const executionEdges = (id: string, data: TaskData | PhaseData) => {
|
|
560
|
+
if ("review" in data) {
|
|
561
|
+
edges.push(
|
|
562
|
+
edge("references", id, repositoryNodeId(data.review.repo)),
|
|
563
|
+
)
|
|
564
|
+
return
|
|
565
|
+
}
|
|
555
566
|
if (!("repo" in data)) return
|
|
556
567
|
edges.push(edge("writes", id, repositoryNodeId(data.repo)))
|
|
557
568
|
for (const reference of data.repos ?? []) {
|
|
@@ -650,8 +661,9 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
650
661
|
const executionDetails = (entityPath: string, data: ExecutionData) =>
|
|
651
662
|
Effect.gen(function* () {
|
|
652
663
|
const directory = entityPath.replace(/\/(?:TASK|PHASE)\.md$/, "")
|
|
653
|
-
const
|
|
654
|
-
const
|
|
664
|
+
const repo = "review" in data ? data.review.repo : data.repo
|
|
665
|
+
const checkoutPath = join(directory, "code", repo)
|
|
666
|
+
const repositoryPath = join(root, "repos", repo)
|
|
655
667
|
const materialized = yield* fs.isDirectory(checkoutPath)
|
|
656
668
|
const result: {
|
|
657
669
|
workspace?: GraphExecutionWorkspace
|
|
@@ -666,58 +678,110 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
666
678
|
}
|
|
667
679
|
}
|
|
668
680
|
if (include.has("git")) {
|
|
669
|
-
result.git =
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
681
|
+
result.git =
|
|
682
|
+
"review" in data
|
|
683
|
+
? {
|
|
684
|
+
pinnedCommit: data.review.commit,
|
|
685
|
+
sourceCommit: ((value) =>
|
|
686
|
+
value?.split(/\s+/)[0] ?? null)(
|
|
687
|
+
yield* run(fs, [
|
|
688
|
+
"git",
|
|
689
|
+
"-C",
|
|
690
|
+
repositoryPath,
|
|
691
|
+
"ls-remote",
|
|
692
|
+
"origin",
|
|
693
|
+
data.review.source.kind === "pull-request"
|
|
694
|
+
? data.review.source.fetchRef
|
|
695
|
+
: data.review.source.ref
|
|
696
|
+
.replace(/^refs\/remotes\/origin\//, "")
|
|
697
|
+
.replace(/^origin\//, ""),
|
|
698
|
+
]),
|
|
699
|
+
),
|
|
700
|
+
checkoutCommit: materialized
|
|
701
|
+
? yield* run(fs, [
|
|
702
|
+
"git",
|
|
703
|
+
"-C",
|
|
704
|
+
checkoutPath,
|
|
705
|
+
"rev-parse",
|
|
706
|
+
"HEAD",
|
|
707
|
+
])
|
|
708
|
+
: null,
|
|
709
|
+
checkoutBranch: materialized
|
|
710
|
+
? yield* run(fs, [
|
|
711
|
+
"git",
|
|
712
|
+
"-C",
|
|
713
|
+
checkoutPath,
|
|
714
|
+
"symbolic-ref",
|
|
715
|
+
"--quiet",
|
|
716
|
+
"--short",
|
|
717
|
+
"HEAD",
|
|
718
|
+
])
|
|
719
|
+
: null,
|
|
720
|
+
dirty: materialized
|
|
721
|
+
? ((status) =>
|
|
722
|
+
status === null ? null : status.length > 0)(
|
|
723
|
+
yield* runText(fs, [
|
|
724
|
+
"git",
|
|
725
|
+
"-C",
|
|
726
|
+
checkoutPath,
|
|
727
|
+
"status",
|
|
728
|
+
"--porcelain",
|
|
729
|
+
]),
|
|
730
|
+
)
|
|
731
|
+
: null,
|
|
732
|
+
}
|
|
733
|
+
: {
|
|
734
|
+
branch: data.branch,
|
|
735
|
+
base: data.base,
|
|
736
|
+
branchCommit: yield* run(fs, [
|
|
710
737
|
"git",
|
|
711
738
|
"-C",
|
|
712
|
-
|
|
713
|
-
"
|
|
714
|
-
|
|
739
|
+
repositoryPath,
|
|
740
|
+
"rev-parse",
|
|
741
|
+
`${data.branch}^{commit}`,
|
|
715
742
|
]),
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
743
|
+
baseCommit: yield* run(fs, [
|
|
744
|
+
"git",
|
|
745
|
+
"-C",
|
|
746
|
+
repositoryPath,
|
|
747
|
+
"rev-parse",
|
|
748
|
+
`${data.base}^{commit}`,
|
|
749
|
+
]),
|
|
750
|
+
checkoutCommit: materialized
|
|
751
|
+
? yield* run(fs, [
|
|
752
|
+
"git",
|
|
753
|
+
"-C",
|
|
754
|
+
checkoutPath,
|
|
755
|
+
"rev-parse",
|
|
756
|
+
"HEAD",
|
|
757
|
+
])
|
|
758
|
+
: null,
|
|
759
|
+
checkoutBranch: materialized
|
|
760
|
+
? yield* run(fs, [
|
|
761
|
+
"git",
|
|
762
|
+
"-C",
|
|
763
|
+
checkoutPath,
|
|
764
|
+
"symbolic-ref",
|
|
765
|
+
"--quiet",
|
|
766
|
+
"--short",
|
|
767
|
+
"HEAD",
|
|
768
|
+
])
|
|
769
|
+
: null,
|
|
770
|
+
dirty: materialized
|
|
771
|
+
? ((status) =>
|
|
772
|
+
status === null ? null : status.length > 0)(
|
|
773
|
+
yield* runText(fs, [
|
|
774
|
+
"git",
|
|
775
|
+
"-C",
|
|
776
|
+
checkoutPath,
|
|
777
|
+
"status",
|
|
778
|
+
"--porcelain",
|
|
779
|
+
]),
|
|
780
|
+
)
|
|
781
|
+
: null,
|
|
782
|
+
}
|
|
719
783
|
}
|
|
720
|
-
if (include.has("pr")) {
|
|
784
|
+
if (include.has("pr") && !("review" in data)) {
|
|
721
785
|
if (!data.pr) {
|
|
722
786
|
result.pr = { url: null, state: "none" }
|
|
723
787
|
} else {
|
|
@@ -97,6 +97,11 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
97
97
|
const taskId = yield* decodeId(input.taskId, "task")
|
|
98
98
|
const id = yield* decodeId(input.id, "phase")
|
|
99
99
|
const task = yield* tasks.show(taskId, root)
|
|
100
|
+
if ("review" in task.data) {
|
|
101
|
+
return yield* new PhaseError({
|
|
102
|
+
message: `Review task '${taskId}' cannot be converted to phases`,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
100
105
|
const isMultiPhase = "phases" in task.data
|
|
101
106
|
let firstPhaseId: string | undefined
|
|
102
107
|
if (!isMultiPhase) {
|
|
@@ -353,7 +353,7 @@ process.exit(${exitCode})
|
|
|
353
353
|
test("blocks a dirty checkout before push or gh", async () => {
|
|
354
354
|
await createTask()
|
|
355
355
|
const workspace = await materialize()
|
|
356
|
-
await Bun.write(join(workspace.writablePath
|
|
356
|
+
await Bun.write(join(workspace.writablePath!, "dirty.txt"), "dirty\n")
|
|
357
357
|
await writeFakeGh({ stdout: "https://github.com/example/agency/pull/45" })
|
|
358
358
|
|
|
359
359
|
await expect(createPullRequest()).rejects.toThrow(
|
|
@@ -518,7 +518,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(providerRecord))})
|
|
|
518
518
|
test("reports git status failure before push or gh", async () => {
|
|
519
519
|
await createTask()
|
|
520
520
|
const workspace = await materialize()
|
|
521
|
-
await rm(join(workspace.writablePath
|
|
521
|
+
await rm(join(workspace.writablePath!, ".git"))
|
|
522
522
|
await writeFakeGh({ stdout: "https://github.com/example/agency/pull/48" })
|
|
523
523
|
|
|
524
524
|
await expect(createPullRequest()).rejects.toThrow(
|
|
@@ -50,6 +50,11 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
50
50
|
const phases = yield* PhaseService
|
|
51
51
|
const root = yield* workbase.discover(startPath)
|
|
52
52
|
const task = yield* tasks.show(taskId, root)
|
|
53
|
+
if ("review" in task.data) {
|
|
54
|
+
return yield* new PullRequestError({
|
|
55
|
+
message: `Review task '${taskId}' cannot record a delivery pull request`,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
53
58
|
const target =
|
|
54
59
|
"phases" in task.data
|
|
55
60
|
? phaseId
|
|
@@ -105,15 +110,20 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
105
110
|
const worktrees = yield* WorktreeService
|
|
106
111
|
const readiness = yield* ReadinessService
|
|
107
112
|
const workbase = yield* WorkbaseService
|
|
108
|
-
const
|
|
113
|
+
const requestedTask = yield* tasks.show(taskId, startPath)
|
|
114
|
+
if ("review" in requestedTask.data) {
|
|
115
|
+
return yield* new PullRequestError({
|
|
116
|
+
message: `Review task '${taskId}' cannot create a delivery pull request`,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
109
119
|
const target =
|
|
110
|
-
"phases" in
|
|
120
|
+
"phases" in requestedTask.data
|
|
111
121
|
? phaseId
|
|
112
122
|
? yield* phases.show(taskId, phaseId, startPath)
|
|
113
123
|
: yield* new PullRequestError({
|
|
114
124
|
message: `Task '${taskId}' requires a phase ID`,
|
|
115
125
|
})
|
|
116
|
-
:
|
|
126
|
+
: requestedTask
|
|
117
127
|
if ("completion" in target.data && target.data.completion) {
|
|
118
128
|
return yield* new PullRequestError({
|
|
119
129
|
message:
|
|
@@ -134,11 +144,21 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
134
144
|
options,
|
|
135
145
|
)
|
|
136
146
|
const workspaceTask = yield* tasks.show(taskId, workspace.root)
|
|
147
|
+
if ("review" in workspaceTask.data) {
|
|
148
|
+
return yield* new PullRequestError({
|
|
149
|
+
message: `Review task '${taskId}' cannot create a delivery pull request`,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
137
152
|
const execution =
|
|
138
153
|
"phases" in workspaceTask.data
|
|
139
154
|
? (yield* phases.show(taskId, phaseId!, workspace.root)).data
|
|
140
155
|
: workspaceTask.data
|
|
141
156
|
const { config } = yield* workbase.loadConfig(workspace.root)
|
|
157
|
+
if (!workspace.writablePath) {
|
|
158
|
+
return yield* new PullRequestError({
|
|
159
|
+
message: `Task '${taskId}' has no writable checkout`,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
142
162
|
const remote = config.delivery?.remote ?? "origin"
|
|
143
163
|
|
|
144
164
|
const status = yield* fs.runCommand(
|
|
@@ -77,7 +77,7 @@ const itemFor = (
|
|
|
77
77
|
? task.data.epic
|
|
78
78
|
: undefined
|
|
79
79
|
const dependentIds = new Set(node.dependents)
|
|
80
|
-
if (node.data.phaseId) {
|
|
80
|
+
if ("phaseId" in node.data && node.data.phaseId) {
|
|
81
81
|
const siblings = graph.nodes.filter(
|
|
82
82
|
(candidate): candidate is ExecutionNode =>
|
|
83
83
|
candidate.kind === "execution-unit" &&
|
|
@@ -93,10 +93,14 @@ const itemFor = (
|
|
|
93
93
|
rank,
|
|
94
94
|
key: node.key,
|
|
95
95
|
taskId: node.data.taskId,
|
|
96
|
-
...(node.data
|
|
96
|
+
...("phaseId" in node.data && node.data.phaseId
|
|
97
|
+
? { phaseId: node.data.phaseId }
|
|
98
|
+
: {}),
|
|
97
99
|
...(node.data.description ? { description: node.data.description } : {}),
|
|
98
100
|
parent: {
|
|
99
|
-
...(node.data
|
|
101
|
+
...("phaseId" in node.data && node.data.phaseId
|
|
102
|
+
? { taskId: node.data.taskId }
|
|
103
|
+
: {}),
|
|
100
104
|
...(epicId ? { epicId } : {}),
|
|
101
105
|
},
|
|
102
106
|
status: node.status,
|