@defrex/autobuild 0.8.0 → 0.10.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 +1 -1
- package/SPEC.md +323 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +166 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +175 -28
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +204 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +14 -0
- package/package.json +45 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/facts.ts +215 -0
- package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
- package/packages/core/src/cli/dashboard/model.ts +141 -153
- package/packages/core/src/cli/dashboard/poll.ts +76 -12
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch-process.ts +1 -13
- package/packages/core/src/cli/dispatch.ts +392 -75
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +62 -9
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +266 -222
- package/packages/core/src/cli/installation.ts +50 -8
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/self-update.ts +77 -2
- package/packages/core/src/cli/status.ts +150 -28
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +453 -8
- package/packages/core/src/cli/wait.ts +157 -41
- package/packages/core/src/cli/watch.ts +138 -168
- package/packages/core/src/config/live.ts +23 -0
- package/packages/core/src/config/pipeline-source.ts +388 -0
- package/packages/core/src/config/schema.ts +251 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +115 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
- package/packages/core/src/kernel/auto-merge-default.ts +157 -0
- package/packages/core/src/kernel/auto-merge.ts +99 -22
- package/packages/core/src/kernel/dispatch-settings.ts +42 -17
- package/packages/core/src/kernel/dispatch-status.ts +23 -0
- package/packages/core/src/kernel/engine.ts +9 -438
- package/packages/core/src/kernel/generators/build-log.ts +259 -0
- package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
- package/packages/core/src/kernel/generators/session-log.ts +72 -0
- package/packages/core/src/kernel/harvest.ts +62 -18
- package/packages/core/src/kernel/incremental-contract.ts +75 -0
- package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
- package/packages/core/src/kernel/incremental.ts +53 -0
- package/packages/core/src/kernel/log-index.ts +620 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/pr-attachments.ts +164 -46
- package/packages/core/src/kernel/reducer-registry.ts +164 -0
- package/packages/core/src/kernel/reducer.ts +254 -73
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +253 -24
- package/packages/core/src/operator/read-view.ts +98 -0
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/fake.ts +5 -1
- package/packages/core/src/ports/tickets/file.ts +169 -48
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/tickets/linear.ts +33 -1
- package/packages/core/src/ports/tickets/operations.ts +1 -1
- package/packages/core/src/ports/tickets/remote.ts +8 -1
- package/packages/core/src/ports/types.ts +49 -3
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +68 -15
- package/packages/core/src/processes/build-runner.ts +238 -93
- package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
- package/packages/core/src/processes/dispatcher.ts +501 -201
- package/packages/core/src/processes/execution-settlement.ts +72 -39
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-execution-state.ts +41 -29
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
- package/packages/core/src/processes/orchestrator-tick.ts +360 -0
- package/packages/core/src/processes/publication-state.ts +164 -52
- package/packages/core/src/processes/repo-view.ts +1436 -0
- package/packages/core/src/processes/sandbox-state.ts +36 -2
- package/packages/core/src/processes/tick-state.ts +33 -0
- package/packages/core/src/store/adapter.ts +41 -0
- package/packages/core/src/store/build-scope.ts +90 -0
- package/packages/core/src/store/contract.ts +1205 -0
- package/packages/core/src/store/digest.ts +102 -0
- package/packages/core/src/store/local/schema.ts +46 -2
- package/packages/core/src/store/local/store.ts +445 -4
- package/packages/core/src/store/memory.ts +246 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +94 -0
- package/packages/core/src/store/remote/client.ts +310 -2
- package/packages/core/src/store/remote/index.ts +14 -2
- package/packages/core/src/store/remote/protocol.ts +115 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +205 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +94 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
- package/packages/core/src/store/snapshots.ts +88 -0
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +458 -0
- package/packages/core/src/store/types.ts +160 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +152 -40
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +204 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/skills/tickets/SKILL.md +57 -5
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- package/packages/core/src/testing/store-failures.ts +0 -32
|
@@ -213,8 +213,14 @@ export function createGitHubFetchTransport(opts: {
|
|
|
213
213
|
// a private repository is otherwise indistinguishable from a bad path,
|
|
214
214
|
// and the operator's fix (export a token, or `gh auth login`) is the
|
|
215
215
|
// point of the message.
|
|
216
|
+
// `in` rather than relying on the `token === undefined` discriminant:
|
|
217
|
+
// the plugin-sdk typecheck fixture compiles this chain without
|
|
218
|
+
// strictNullChecks, where `undefined` is assignable to `string` and
|
|
219
|
+
// the union stops discriminating (AUT-517).
|
|
216
220
|
const anonymous =
|
|
217
|
-
credential.token === undefined
|
|
221
|
+
credential.token === undefined && 'reason' in credential
|
|
222
|
+
? ` (request was unauthenticated: ${credential.reason})`
|
|
223
|
+
: ''
|
|
218
224
|
throw new GitHubApiError(
|
|
219
225
|
response.status,
|
|
220
226
|
`${githubErrorMessage(response.status, json, text)}${anonymous}`,
|
|
@@ -335,9 +341,14 @@ export async function resolveGitHubToken(
|
|
|
335
341
|
if (fromEnv !== undefined) return { token: fromEnv }
|
|
336
342
|
const probe = await githubTokenFromGhCli(exec)
|
|
337
343
|
if (probe.token !== undefined) return probe
|
|
344
|
+
// `in` rather than relying on the `token !== undefined` discriminant: the
|
|
345
|
+
// plugin-sdk typecheck fixture compiles this chain without
|
|
346
|
+
// strictNullChecks, where `undefined` is assignable to `string` and the
|
|
347
|
+
// union stops discriminating (AUT-517). A miss always carries its reason.
|
|
348
|
+
const missReason = 'reason' in probe ? probe.reason : 'no credential source succeeded'
|
|
338
349
|
return {
|
|
339
350
|
token: undefined,
|
|
340
|
-
reason: `GITHUB_TOKEN and GH_TOKEN are unset and ${
|
|
351
|
+
reason: `GITHUB_TOKEN and GH_TOKEN are unset and ${missReason}`,
|
|
341
352
|
}
|
|
342
353
|
}
|
|
343
354
|
|
|
@@ -470,6 +470,16 @@ function restMergeState(raw: string): MergeStateStatus {
|
|
|
470
470
|
|
|
471
471
|
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
|
472
472
|
|
|
473
|
+
/** Bounded UNKNOWN re-query schedule: 7 re-queries, ~30 s of nondecreasing
|
|
474
|
+
* backoff capped at 6 s steps. Sized after AUT-392 — the prior
|
|
475
|
+
* two-re-query/2 s budget (`[500, 1500]`) was exhausted by a live occurrence
|
|
476
|
+
* (build `give-repo-scoped`, PR #352) while GitHub was still computing
|
|
477
|
+
* mergeability after required checks completed. Single-occurrence evidence,
|
|
478
|
+
* so this is a deliberate sizing bet, not a measured latency distribution;
|
|
479
|
+
* the unchanged pendingAutoMerge tick backstop remains behind it for waits a
|
|
480
|
+
* single tick cannot absorb. */
|
|
481
|
+
export const MERGEABILITY_RETRY_DELAYS_MS = [1000, 2000, 4000, 5000, 6000, 6000, 6000]
|
|
482
|
+
|
|
473
483
|
/** Hard cap on the janitor poll's per-PR ETag cache. One small entry per PR
|
|
474
484
|
* ever polled in a dispatcher process lifetime makes hitting this in practice
|
|
475
485
|
* implausible; the cap is hygiene, not a load-bearing bound. */
|
|
@@ -514,7 +524,8 @@ export class GitHubForge implements Forge {
|
|
|
514
524
|
env?: Readonly<Record<string, string | undefined>>
|
|
515
525
|
/** Bounded waits between mergeability re-queries when GitHub reports
|
|
516
526
|
* `mergeable_state: 'unknown'` (mergeability still being computed).
|
|
517
|
-
*
|
|
527
|
+
* Defaults to `MERGEABILITY_RETRY_DELAYS_MS` (7 re-queries, ~30 s);
|
|
528
|
+
* tests inject `[0, 0]` or all-zero schedules so no test sleeps. */
|
|
518
529
|
mergeabilityRetryDelaysMs?: number[]
|
|
519
530
|
} = {},
|
|
520
531
|
) {
|
|
@@ -535,7 +546,7 @@ export class GitHubForge implements Forge {
|
|
|
535
546
|
this.explicitRepository = this.env.AB_REPOSITORY
|
|
536
547
|
}
|
|
537
548
|
this.repoRoot = opts.repoRoot
|
|
538
|
-
this.mergeabilityRetryDelaysMs = opts.mergeabilityRetryDelaysMs ??
|
|
549
|
+
this.mergeabilityRetryDelaysMs = opts.mergeabilityRetryDelaysMs ?? MERGEABILITY_RETRY_DELAYS_MS
|
|
539
550
|
this.prAttachments = new GitHubPrAttachmentHosting({ transport: this.transport })
|
|
540
551
|
}
|
|
541
552
|
|
|
@@ -40,9 +40,39 @@ function detail(error: unknown): string {
|
|
|
40
40
|
export class LocalGitForge implements Forge {
|
|
41
41
|
readonly name = 'local-git'
|
|
42
42
|
private readonly exec: Exec
|
|
43
|
+
/** The repository the optional checkout-less read seam reads from. The
|
|
44
|
+
* factory passes the served checkout; without it the capability is absent
|
|
45
|
+
* and callers retain their checkout/git fallbacks. */
|
|
46
|
+
private readonly repoRoot?: string
|
|
43
47
|
|
|
44
|
-
constructor(opts: { exec?: Exec } = {}) {
|
|
48
|
+
constructor(opts: { exec?: Exec; repoRoot?: string } = {}) {
|
|
45
49
|
this.exec = opts.exec ?? bunExec
|
|
50
|
+
this.repoRoot = opts.repoRoot
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Current tip of a local branch in the served repository. Throws when the
|
|
54
|
+
* branch does not exist — the same not-found contract as remote adapters. */
|
|
55
|
+
async remoteBranchSha(branch: string): Promise<string> {
|
|
56
|
+
if (this.repoRoot === undefined) {
|
|
57
|
+
throw new Error('local-git forge has no repository root for remote branch reads')
|
|
58
|
+
}
|
|
59
|
+
const result = await this.command(
|
|
60
|
+
['rev-parse', '--verify', `refs/heads/${branch}`],
|
|
61
|
+
this.repoRoot,
|
|
62
|
+
true,
|
|
63
|
+
)
|
|
64
|
+
if (result.exitCode !== 0) throw new Error(`local-git: unknown remote branch ${branch}`)
|
|
65
|
+
return result.stdout.trim()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** File bytes from the served repository. Ref omitted → the checkout's HEAD.
|
|
69
|
+
* Throws when the path does not exist at that ref. */
|
|
70
|
+
async readFile(path: string, ref?: string): Promise<string> {
|
|
71
|
+
if (this.repoRoot === undefined) {
|
|
72
|
+
throw new Error('local-git forge has no repository root for file reads')
|
|
73
|
+
}
|
|
74
|
+
const result = await this.command(['show', `${ref ?? 'HEAD'}:${path}`], this.repoRoot)
|
|
75
|
+
return result.stdout
|
|
46
76
|
}
|
|
47
77
|
|
|
48
78
|
private async command(args: string[], cwd: string, allowFailure = false) {
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
toolInputPart,
|
|
39
39
|
toolOutputPart,
|
|
40
40
|
} from './stream-parts'
|
|
41
|
-
import { classifyProviderError,
|
|
41
|
+
import { classifyProviderError, missingExecutableFailure } from './provider-error'
|
|
42
42
|
import { sessionEnv } from './session-env'
|
|
43
43
|
import type { OneShotCompletion, OneShotCompletionInput, OneShotCompletionResult } from './one-shot'
|
|
44
44
|
import type { RuntimeUsabilityInput, RuntimeUsabilityResult } from './runtime'
|
|
@@ -652,7 +652,7 @@ function launchFailure(error: unknown): ClaudeTurn {
|
|
|
652
652
|
return {
|
|
653
653
|
text: '',
|
|
654
654
|
usage: { inputTokens: 0, outputTokens: 0 },
|
|
655
|
-
failure: missing ?
|
|
655
|
+
failure: missing ? missingExecutableFailure(message) : classifyProviderError(message),
|
|
656
656
|
cli: {
|
|
657
657
|
stdout: '',
|
|
658
658
|
stderr: errorText(error),
|
|
@@ -17,7 +17,11 @@ import {
|
|
|
17
17
|
type Transcript,
|
|
18
18
|
} from '../types'
|
|
19
19
|
import type { OneShotCompletion, OneShotCompletionInput, OneShotCompletionResult } from './one-shot'
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
classifyProviderError,
|
|
22
|
+
credentialFailure,
|
|
23
|
+
missingExecutableFailure,
|
|
24
|
+
} from './provider-error'
|
|
21
25
|
import {
|
|
22
26
|
abortPart,
|
|
23
27
|
errorPart,
|
|
@@ -694,7 +698,7 @@ function launchFailure(error: unknown): CodexTurn {
|
|
|
694
698
|
return {
|
|
695
699
|
text: '',
|
|
696
700
|
usage: { inputTokens: 0, outputTokens: 0 },
|
|
697
|
-
failure: missing ?
|
|
701
|
+
failure: missing ? missingExecutableFailure(message) : classifyProviderError(message),
|
|
698
702
|
cli: { stdout: '', stderr: errorText(error), exitCode: -1 },
|
|
699
703
|
events: [],
|
|
700
704
|
malformedLines: [],
|
|
@@ -49,12 +49,18 @@ export function failedTurnResult(
|
|
|
49
49
|
permanent: boolean,
|
|
50
50
|
text = '',
|
|
51
51
|
cause?: AgentFailureCause,
|
|
52
|
+
missingExecutable?: true,
|
|
52
53
|
): AgentTurnResult {
|
|
53
54
|
return {
|
|
54
55
|
kind: 'failed',
|
|
55
56
|
text,
|
|
56
57
|
usage: { inputTokens: 0, outputTokens: 0, turns: 1 },
|
|
57
|
-
failure: {
|
|
58
|
+
failure: {
|
|
59
|
+
message,
|
|
60
|
+
permanent,
|
|
61
|
+
...(cause !== undefined ? { cause } : {}),
|
|
62
|
+
...(missingExecutable === true ? { missingExecutable } : {}),
|
|
63
|
+
},
|
|
58
64
|
}
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -13,7 +13,12 @@ import {
|
|
|
13
13
|
type Transcript,
|
|
14
14
|
} from '../types'
|
|
15
15
|
import type { OneShotCompletion, OneShotCompletionInput, OneShotCompletionResult } from './one-shot'
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
classifyProviderError,
|
|
18
|
+
configurationFailure,
|
|
19
|
+
credentialFailure,
|
|
20
|
+
missingExecutableFailure,
|
|
21
|
+
} from './provider-error'
|
|
17
22
|
import { createPiRpcSession, PI_MODEL_ARG, PI_RPC_MODE_ARG } from './pi-rpc'
|
|
18
23
|
import {
|
|
19
24
|
abortPart,
|
|
@@ -586,11 +591,8 @@ export class PiAgentRunner implements AgentRunner, OneShotCompletion {
|
|
|
586
591
|
|
|
587
592
|
function localPiFailure(error: unknown): AgentTurnFailure {
|
|
588
593
|
const message = errorText(error)
|
|
589
|
-
if (
|
|
590
|
-
|
|
591
|
-
message,
|
|
592
|
-
)
|
|
593
|
-
) {
|
|
594
|
+
if (/executable "pi" was not found/.test(message)) return missingExecutableFailure(message)
|
|
595
|
+
if (/requires Pi .* or newer|could not parse detected Pi version/.test(message)) {
|
|
594
596
|
return configurationFailure(message)
|
|
595
597
|
}
|
|
596
598
|
if (/not ready|authentication|logged in|credentials/i.test(message))
|
|
@@ -77,9 +77,12 @@ export function classifyProviderError(
|
|
|
77
77
|
/** Whether another declared target may be tried inside this phase attempt.
|
|
78
78
|
* Legacy plugins have no cause: preserve their old safe split. */
|
|
79
79
|
export function isAlternateEligible(failure: AgentTurnFailure): boolean {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
if (failure.cause === undefined) return !failure.permanent
|
|
81
|
+
return (
|
|
82
|
+
failure.cause === 'availability' ||
|
|
83
|
+
failure.cause === 'exhaustion' ||
|
|
84
|
+
(failure.cause === 'configuration' && failure.missingExecutable === true)
|
|
85
|
+
)
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
/** Final-failure retry behavior remains governed by the compatibility bit. */
|
|
@@ -92,6 +95,13 @@ export function configurationFailure(message: string): AgentTurnFailure {
|
|
|
92
95
|
return { message, permanent: true, cause: 'configuration' }
|
|
93
96
|
}
|
|
94
97
|
|
|
98
|
+
/** The runtime's CLI executable is not installed. Still a permanent
|
|
99
|
+
* `configuration` failure (never retried), but a later declared target whose
|
|
100
|
+
* CLI is installed may serve the phase. */
|
|
101
|
+
export function missingExecutableFailure(message: string): AgentTurnFailure {
|
|
102
|
+
return { message, permanent: true, cause: 'configuration', missingExecutable: true }
|
|
103
|
+
}
|
|
104
|
+
|
|
95
105
|
/** Explicit credential failures bypass both alternates and phase retries. */
|
|
96
106
|
export function credentialFailure(message: string): AgentTurnFailure {
|
|
97
107
|
return { message, permanent: true, cause: 'credentials' }
|
|
@@ -79,7 +79,11 @@ export class FakeTicketSource implements TicketSource {
|
|
|
79
79
|
this.tickets.set(ticket.ref.id, cloneTicket(ticket))
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
async listReady(criteria: {
|
|
82
|
+
async listReady(criteria: {
|
|
83
|
+
labels?: string[]
|
|
84
|
+
state?: string
|
|
85
|
+
assignee?: string
|
|
86
|
+
}): Promise<TicketListing> {
|
|
83
87
|
const labels = criteria.labels ?? []
|
|
84
88
|
return {
|
|
85
89
|
tickets: [...this.tickets.values()]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* File-based TicketSource (SPEC §3.2, §13): the default tracker. A directory
|
|
3
|
-
* of state directories — `triage/ ready/ doing/ done/`
|
|
3
|
+
* of state directories — the lifecycle four `triage/ ready/ doing/ done/`
|
|
4
|
+
* plus any the repository adds, such as `icebox/` — each holding
|
|
4
5
|
* `<id>.md` files: TOML frontmatter between `+++` fences, then the ticket
|
|
5
6
|
* body, where the spec lives pre-build (§6.3). This adapter is the policy's
|
|
6
7
|
* proof: a source with nowhere to put blobs must be fully workable, because
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
* human edits are explicitly last-write-wins. State renames never cross a
|
|
20
21
|
* filesystem: `<dir>/ready` → `<dir>/doing` is one mount by construction.
|
|
21
22
|
*/
|
|
23
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
22
24
|
import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
|
23
25
|
import { join } from 'node:path'
|
|
24
26
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
@@ -31,40 +33,64 @@ import type {
|
|
|
31
33
|
TicketDraft,
|
|
32
34
|
TicketListing,
|
|
33
35
|
TicketSource,
|
|
36
|
+
TicketStateInfo,
|
|
34
37
|
TicketUpdate,
|
|
35
38
|
} from '../types'
|
|
36
39
|
import { validateTicketUpdate } from './update'
|
|
37
40
|
|
|
41
|
+
/** Repo-relative fallback when no selected local-state root is supplied. */
|
|
42
|
+
export const DEFAULT_TICKETS_DIR = '.autobuild/tickets'
|
|
43
|
+
|
|
38
44
|
/**
|
|
39
|
-
* The
|
|
40
|
-
*
|
|
41
|
-
* let config invent new ones.
|
|
45
|
+
* The directories the dispatcher's roles (create, ready, claim, triage, done)
|
|
46
|
+
* are played by. Always valid state names, because `ensureLayout` creates them.
|
|
42
47
|
*/
|
|
43
|
-
|
|
44
|
-
export type TicketState = (typeof TICKET_STATES)[number]
|
|
48
|
+
const LIFECYCLE_DIRS = ['triage', 'ready', 'doing', 'done'] as const
|
|
45
49
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
50
|
+
/** Dotfile inside a state directory holding its one-line purpose. Dot entries
|
|
51
|
+
* and non-`.md` files are never tickets, so it can never be mistaken for one. */
|
|
52
|
+
const ABOUT_FILE = '.about'
|
|
53
|
+
|
|
54
|
+
const STATE_NAME_RULE =
|
|
55
|
+
'a state name is one to forty characters of lowercase letters, digits, and hyphens, starting with a letter'
|
|
56
|
+
const STATE_NAME_RE = /^[a-z][a-z0-9-]{0,39}$/
|
|
57
|
+
|
|
58
|
+
/** Throws, stating the rule, unless `name` may be used to create a state. */
|
|
59
|
+
export function validateStateName(name: string): void {
|
|
60
|
+
if (!STATE_NAME_RE.test(name)) {
|
|
61
|
+
throw new Error(`file ticket source: invalid state name "${name}" — ${STATE_NAME_RULE}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The state names a root listing yields: the lifecycle four, then every other
|
|
66
|
+
* non-dot directory alphabetically. Dot entries and files are never states. */
|
|
67
|
+
function statesIn(directories: string[]): string[] {
|
|
68
|
+
const lifecycle: string[] = [...LIFECYCLE_DIRS]
|
|
69
|
+
const extra = directories
|
|
70
|
+
.filter((name) => !name.startsWith('.') && !lifecycle.includes(name))
|
|
71
|
+
.sort()
|
|
72
|
+
return [...lifecycle, ...extra]
|
|
73
|
+
}
|
|
48
74
|
|
|
49
75
|
/**
|
|
50
|
-
*
|
|
51
|
-
* `readyState = "ready"` means the `ready/` directory),
|
|
52
|
-
*
|
|
53
|
-
* create a
|
|
76
|
+
* Resolve a state name to its directory. Case-insensitive in (a config's
|
|
77
|
+
* `readyState = "ready"` means the `ready/` directory), the directory's real
|
|
78
|
+
* name out — and a name matching no directory is a loud error, because a
|
|
79
|
+
* typo'd state must not silently create a directory no `ls` would show.
|
|
54
80
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
81
|
+
function resolveState(name: string, existing: string[]): string {
|
|
82
|
+
if (existing.includes(name)) return name
|
|
83
|
+
const matches = existing.filter((s) => s.toLowerCase() === name.toLowerCase())
|
|
84
|
+
if (matches.length === 1) return matches[0] as string
|
|
85
|
+
if (matches.length > 1) {
|
|
58
86
|
throw new Error(
|
|
59
|
-
`file ticket source:
|
|
60
|
-
`the directories: ${TICKET_STATES.join(', ')}`,
|
|
87
|
+
`file ticket source: ambiguous state "${name}" — it matches the directories ${matches.join(', ')}`,
|
|
61
88
|
)
|
|
62
89
|
}
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return state.toLowerCase()
|
|
90
|
+
throw new Error(
|
|
91
|
+
`file ticket source: unknown state "${name}" — this tracker's states are ` +
|
|
92
|
+
`the directories: ${existing.join(', ')}`,
|
|
93
|
+
)
|
|
68
94
|
}
|
|
69
95
|
|
|
70
96
|
const frontmatterSchema = z.strictObject({
|
|
@@ -138,7 +164,7 @@ function serializeTicketFile(front: Frontmatter, body: string): string {
|
|
|
138
164
|
/** A ticket file found on disk: its id, its state (= its directory), its path. */
|
|
139
165
|
interface Located {
|
|
140
166
|
id: string
|
|
141
|
-
state:
|
|
167
|
+
state: string
|
|
142
168
|
path: string
|
|
143
169
|
}
|
|
144
170
|
|
|
@@ -147,8 +173,8 @@ export class FileTicketSource implements TicketSource {
|
|
|
147
173
|
|
|
148
174
|
private readonly dir: string
|
|
149
175
|
private readonly clock: Clock
|
|
150
|
-
private readonly createState:
|
|
151
|
-
private readonly doneState:
|
|
176
|
+
private readonly createState: string
|
|
177
|
+
private readonly doneState: string
|
|
152
178
|
private readonly selfIgnore: boolean
|
|
153
179
|
|
|
154
180
|
constructor(opts: {
|
|
@@ -174,14 +200,20 @@ export class FileTicketSource implements TicketSource {
|
|
|
174
200
|
}) {
|
|
175
201
|
this.dir = opts.dir
|
|
176
202
|
this.clock = opts.clock ?? systemClock
|
|
177
|
-
|
|
178
|
-
this.
|
|
203
|
+
const existing = this.statesSync()
|
|
204
|
+
this.createState = resolveState(opts.createState ?? 'Triage', existing)
|
|
205
|
+
this.doneState = resolveState(opts.doneState ?? 'Done', existing)
|
|
179
206
|
this.selfIgnore = opts.selfIgnore ?? false
|
|
180
207
|
}
|
|
181
208
|
|
|
182
|
-
async listReady(criteria: {
|
|
209
|
+
async listReady(criteria: {
|
|
210
|
+
labels?: string[]
|
|
211
|
+
state?: string
|
|
212
|
+
assignee?: string
|
|
213
|
+
}): Promise<TicketListing> {
|
|
183
214
|
const labels = criteria.labels ?? []
|
|
184
|
-
const state =
|
|
215
|
+
const state =
|
|
216
|
+
criteria.state === undefined ? undefined : resolveState(criteria.state, await this.states())
|
|
185
217
|
// Through listAll, not a single readdir: scan completes its layout and
|
|
186
218
|
// duplicate-id checks before any per-record validation is contained. A
|
|
187
219
|
// malformed record can never weaken the invariant preventing a `cp`
|
|
@@ -209,8 +241,8 @@ export class FileTicketSource implements TicketSource {
|
|
|
209
241
|
* the claim record — no `claimedBy` field, which is what makes "claiming
|
|
210
242
|
* visibly removes it from ready/" true.
|
|
211
243
|
*
|
|
212
|
-
* The guard refuses a ticket already in
|
|
213
|
-
* terminal state rather than requiring one in
|
|
244
|
+
* The guard refuses a ticket already in doing or in this source's configured
|
|
245
|
+
* terminal state rather than requiring one in ready. That inversion is
|
|
214
246
|
* deliberate: a legal `[tickets] readyState = "Triage"` would otherwise
|
|
215
247
|
* stall forever. Other nonterminal states remain claimable after an explicit
|
|
216
248
|
* handback, while terminal work can never be reopened by claim.
|
|
@@ -224,8 +256,8 @@ export class FileTicketSource implements TicketSource {
|
|
|
224
256
|
// this closes the list→claim window where a human could corrupt a record
|
|
225
257
|
// after it was listed but before the dispatcher claims it.
|
|
226
258
|
await this.loadAt(found)
|
|
227
|
-
if (found.state === '
|
|
228
|
-
await rename(found.path, this.pathIn('
|
|
259
|
+
if (found.state === 'doing' || found.state === this.doneState) return false
|
|
260
|
+
await rename(found.path, this.pathIn('doing', id))
|
|
229
261
|
return true
|
|
230
262
|
}
|
|
231
263
|
|
|
@@ -250,7 +282,7 @@ export class FileTicketSource implements TicketSource {
|
|
|
250
282
|
/** A move, never a rewrite — which is why the body survives byte-exactly. */
|
|
251
283
|
async transition(id: string, state: string): Promise<void> {
|
|
252
284
|
await this.ensureLayout()
|
|
253
|
-
const target =
|
|
285
|
+
const target = resolveState(state, await this.states())
|
|
254
286
|
const found = await this.locate(id)
|
|
255
287
|
if (found === null) {
|
|
256
288
|
throw new Error(`file ticket source: transition on unknown ticket "${id}"`)
|
|
@@ -270,9 +302,16 @@ export class FileTicketSource implements TicketSource {
|
|
|
270
302
|
* or adopts the ticket carrying the same idempotency key. */
|
|
271
303
|
async create(draft: TicketDraft, opts: TicketCreateOptions = {}): Promise<Ticket> {
|
|
272
304
|
await this.ensureLayout()
|
|
305
|
+
// Resolve before anything is read or written: an unknown state touches no file.
|
|
306
|
+
const targetState =
|
|
307
|
+
opts.state === undefined ? this.createState : resolveState(opts.state, await this.states())
|
|
273
308
|
const located = await this.scan()
|
|
274
309
|
if (opts.idempotencyKey !== undefined) {
|
|
275
|
-
const matches: Array<{
|
|
310
|
+
const matches: Array<{
|
|
311
|
+
found: Located
|
|
312
|
+
front: Frontmatter
|
|
313
|
+
body: string
|
|
314
|
+
}> = []
|
|
276
315
|
for (const found of located) {
|
|
277
316
|
const loaded = await this.loadAt(found)
|
|
278
317
|
if (loaded.front.idempotencyKey === opts.idempotencyKey) {
|
|
@@ -301,7 +340,6 @@ export class FileTicketSource implements TicketSource {
|
|
|
301
340
|
: {}),
|
|
302
341
|
...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}),
|
|
303
342
|
}
|
|
304
|
-
const targetState = opts.state === undefined ? this.createState : stateDir(opts.state)
|
|
305
343
|
await writeFile(this.pathIn(targetState, front.id), serializeTicketFile(front, draft.body))
|
|
306
344
|
return this.toTicket(front, draft.body, targetState)
|
|
307
345
|
}
|
|
@@ -397,22 +435,105 @@ export class FileTicketSource implements TicketSource {
|
|
|
397
435
|
return states
|
|
398
436
|
}
|
|
399
437
|
|
|
438
|
+
/** Every state with its ticket count and purpose. Reads directories only —
|
|
439
|
+
* never parses a ticket — so a malformed record cannot hide the states. */
|
|
440
|
+
async listStates(): Promise<TicketStateInfo[]> {
|
|
441
|
+
const infos: TicketStateInfo[] = []
|
|
442
|
+
for (const name of await this.states()) infos.push(await this.stateInfo(name))
|
|
443
|
+
return infos
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async addState(
|
|
447
|
+
name: string,
|
|
448
|
+
opts: { about?: string } = {},
|
|
449
|
+
): Promise<{ state: TicketStateInfo; created: boolean }> {
|
|
450
|
+
validateStateName(name)
|
|
451
|
+
const about = opts.about === undefined ? undefined : opts.about.trim()
|
|
452
|
+
if (about !== undefined && (about === '' || /[\r\n]/.test(about))) {
|
|
453
|
+
throw new Error('file ticket source: a state purpose must be one nonblank line')
|
|
454
|
+
}
|
|
455
|
+
const existing = await this.states()
|
|
456
|
+
const clash = existing.find((s) => s !== name && s.toLowerCase() === name.toLowerCase())
|
|
457
|
+
if (clash !== undefined) {
|
|
458
|
+
throw new Error(
|
|
459
|
+
`file ticket source: state "${name}" collides with the existing state "${clash}" — ${STATE_NAME_RULE}, ` +
|
|
460
|
+
'and state names are compared case-insensitively',
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
const created = !existing.includes(name)
|
|
464
|
+
await this.ensureLayout()
|
|
465
|
+
if (created) await mkdir(join(this.dir, name))
|
|
466
|
+
if (about !== undefined) await writeFile(join(this.dir, name, ABOUT_FILE), `${about}\n`)
|
|
467
|
+
return { state: await this.stateInfo(name), created }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private async stateInfo(name: string): Promise<TicketStateInfo> {
|
|
471
|
+
const tickets = (await this.readMarkdown(join(this.dir, name))).length
|
|
472
|
+
let about: string | undefined
|
|
473
|
+
try {
|
|
474
|
+
about = (await readFile(join(this.dir, name, ABOUT_FILE), 'utf8')).trim()
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
477
|
+
}
|
|
478
|
+
return { name, tickets, ...(about ? { about } : {}) }
|
|
479
|
+
}
|
|
480
|
+
|
|
400
481
|
/**
|
|
401
|
-
* The four
|
|
482
|
+
* The four lifecycle dirs (never a custom state: `addState` is the
|
|
483
|
+
* explicit act), plus — for the defaulted backlog only — a
|
|
402
484
|
* self-excluding `.gitignore`. Same move `src/cli/context.ts` makes for
|
|
403
485
|
* `.ab/`: the dir hides itself from git in ANY repo, so the local backlog is
|
|
404
486
|
* never staged, without mutating the repo's own tracked `.gitignore` as an
|
|
405
487
|
* unreviewed side effect. Idempotent; called at the head of every write.
|
|
406
488
|
*/
|
|
407
489
|
private async ensureLayout(): Promise<void> {
|
|
408
|
-
for (const state of
|
|
409
|
-
await mkdir(join(this.dir,
|
|
490
|
+
for (const state of LIFECYCLE_DIRS) {
|
|
491
|
+
await mkdir(join(this.dir, state), { recursive: true })
|
|
410
492
|
}
|
|
411
493
|
if (this.selfIgnore) await writeFile(join(this.dir, '.gitignore'), '*\n')
|
|
412
494
|
}
|
|
413
495
|
|
|
414
|
-
|
|
415
|
-
|
|
496
|
+
/** The state names that exist now. Re-read per operation so a directory
|
|
497
|
+
* created while a long-running dispatcher is up is seen. */
|
|
498
|
+
private async states(): Promise<string[]> {
|
|
499
|
+
try {
|
|
500
|
+
const entries = await readdir(this.dir, { withFileTypes: true })
|
|
501
|
+
const directories: string[] = []
|
|
502
|
+
for (const entry of entries) {
|
|
503
|
+
if (entry.isDirectory()) directories.push(entry.name)
|
|
504
|
+
else if (entry.isSymbolicLink()) {
|
|
505
|
+
const target = await stat(join(this.dir, entry.name)).catch(() => null)
|
|
506
|
+
if (target?.isDirectory()) directories.push(entry.name)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return statesIn(directories)
|
|
510
|
+
} catch (error) {
|
|
511
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return statesIn([])
|
|
512
|
+
throw error
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Constructor-time twin of `states()`: the only sync IO in this adapter. */
|
|
517
|
+
private statesSync(): string[] {
|
|
518
|
+
try {
|
|
519
|
+
const entries = readdirSync(this.dir, { withFileTypes: true })
|
|
520
|
+
const directories = entries
|
|
521
|
+
.filter(
|
|
522
|
+
(e) =>
|
|
523
|
+
e.isDirectory() ||
|
|
524
|
+
(e.isSymbolicLink() &&
|
|
525
|
+
statSync(join(this.dir, e.name), { throwIfNoEntry: false })?.isDirectory()),
|
|
526
|
+
)
|
|
527
|
+
.map((e) => e.name)
|
|
528
|
+
return statesIn(directories)
|
|
529
|
+
} catch (error) {
|
|
530
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return statesIn([])
|
|
531
|
+
throw error
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private pathIn(state: string, id: string): string {
|
|
536
|
+
return join(this.dir, state, `${this.safeId(id)}.md`)
|
|
416
537
|
}
|
|
417
538
|
|
|
418
539
|
private safeId(id: string): string {
|
|
@@ -426,7 +547,7 @@ export class FileTicketSource implements TicketSource {
|
|
|
426
547
|
private async locate(id: string): Promise<Located | null> {
|
|
427
548
|
this.safeId(id)
|
|
428
549
|
const hits: Located[] = []
|
|
429
|
-
for (const state of
|
|
550
|
+
for (const state of await this.states()) {
|
|
430
551
|
const path = this.pathIn(state, id)
|
|
431
552
|
try {
|
|
432
553
|
await stat(path)
|
|
@@ -443,14 +564,14 @@ export class FileTicketSource implements TicketSource {
|
|
|
443
564
|
return hits[0] ?? null
|
|
444
565
|
}
|
|
445
566
|
|
|
446
|
-
/** Every ticket file across
|
|
567
|
+
/** Every ticket file across every state dir, sorted, duplicates rejected. */
|
|
447
568
|
private async scan(): Promise<Located[]> {
|
|
448
569
|
await this.assertNoLooseTickets()
|
|
449
570
|
const byId = new Map<string, Located[]>()
|
|
450
|
-
for (const state of
|
|
451
|
-
for (const entry of await this.readMarkdown(join(this.dir,
|
|
571
|
+
for (const state of await this.states()) {
|
|
572
|
+
for (const entry of await this.readMarkdown(join(this.dir, state))) {
|
|
452
573
|
const id = entry.slice(0, -'.md'.length)
|
|
453
|
-
const located = { id, state, path: join(this.dir,
|
|
574
|
+
const located = { id, state, path: join(this.dir, state, entry) }
|
|
454
575
|
byId.set(id, [...(byId.get(id) ?? []), located])
|
|
455
576
|
}
|
|
456
577
|
}
|
|
@@ -473,7 +594,7 @@ export class FileTicketSource implements TicketSource {
|
|
|
473
594
|
throw new Error(
|
|
474
595
|
`${join(this.dir, entry)}: ticket file outside a state directory — a ticket's ` +
|
|
475
596
|
`state is the directory it sits in; move it into one of: ` +
|
|
476
|
-
`${
|
|
597
|
+
`${(await this.states()).join('/, ')}/`,
|
|
477
598
|
)
|
|
478
599
|
}
|
|
479
600
|
}
|
|
@@ -535,7 +656,7 @@ export class FileTicketSource implements TicketSource {
|
|
|
535
656
|
return { tickets, diagnostics }
|
|
536
657
|
}
|
|
537
658
|
|
|
538
|
-
private toTicket(front: Frontmatter, body: string, state:
|
|
659
|
+
private toTicket(front: Frontmatter, body: string, state: string): Ticket {
|
|
539
660
|
return {
|
|
540
661
|
ref: { source: this.name, id: front.id, title: front.title },
|
|
541
662
|
...(front.idempotencyKey !== undefined ? { creationKey: front.idempotencyKey } : {}),
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
// Client half of the hosted ticket source (SPEC §13): the TicketSource a
|
|
2
|
+
// local CLI drives against a hosted deployment. The HTTP server half ships
|
|
3
|
+
// in `@defrex/autobuild-hosted-store-service`.
|
|
1
4
|
export { HostedTicketSource } from './remote'
|
|
2
5
|
export type { HostedTicketFetch, HostedTicketSourceOptions } from './remote'
|
|
3
|
-
export { createTicketServer } from './remote-server'
|
|
4
|
-
export type { TicketServerOptions } from './remote-server'
|
|
5
6
|
export {
|
|
6
7
|
HOSTED_TICKET_OPERATIONS,
|
|
7
8
|
hostedTicketContextSchema,
|
|
@@ -238,10 +238,42 @@ export class LinearTicketSource implements TicketSource {
|
|
|
238
238
|
return [...(await this.getTeamInfo('workflow states')).stateIds.keys()]
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
|
|
241
|
+
/** The Linear user a `readyAssignee` value names: `me` is the API key's
|
|
242
|
+
* owner, anything else is an email. Null when no user matches. */
|
|
243
|
+
async resolveAssignee(
|
|
244
|
+
assignee: string,
|
|
245
|
+
): Promise<{ id: string; name: string; email: string } | null> {
|
|
246
|
+
if (assignee === 'me') {
|
|
247
|
+
const data = await this.gql<{ viewer: { id: string; name: string; email: string } | null }>(
|
|
248
|
+
'resolveAssignee',
|
|
249
|
+
'query Viewer { viewer { id name email } }',
|
|
250
|
+
{},
|
|
251
|
+
)
|
|
252
|
+
return data.viewer ?? null
|
|
253
|
+
}
|
|
254
|
+
const data = await this.gql<{
|
|
255
|
+
users: { nodes: { id: string; name: string; email: string }[] }
|
|
256
|
+
}>(
|
|
257
|
+
'resolveAssignee',
|
|
258
|
+
'query UserByEmail($email: String!) { users(filter: { email: { eq: $email } }) { nodes { id name email } } }',
|
|
259
|
+
{ email: assignee },
|
|
260
|
+
)
|
|
261
|
+
return data.users.nodes[0] ?? null
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async listReady(criteria: {
|
|
265
|
+
labels?: string[]
|
|
266
|
+
state?: string
|
|
267
|
+
assignee?: string
|
|
268
|
+
}): Promise<TicketListing> {
|
|
242
269
|
const filter: Record<string, unknown> = {
|
|
243
270
|
team: { key: { eq: this.teamKey } },
|
|
244
271
|
}
|
|
272
|
+
if (criteria.assignee !== undefined) {
|
|
273
|
+
// An unassigned issue matches neither predicate, so it is never ready.
|
|
274
|
+
filter.assignee =
|
|
275
|
+
criteria.assignee === 'me' ? { isMe: { eq: true } } : { email: { eq: criteria.assignee } }
|
|
276
|
+
}
|
|
245
277
|
if (criteria.state !== undefined) {
|
|
246
278
|
filter.state = { name: { eq: criteria.state } }
|
|
247
279
|
}
|