@valescoagency/runway 0.3.0 → 0.5.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 +107 -10
- package/dist/commands/doctor.js +203 -2
- package/dist/commands/run.js +70 -15
- package/dist/config.js +53 -61
- package/dist/git.js +43 -29
- package/dist/github.js +136 -21
- package/dist/linear.js +295 -63
- package/dist/orchestrator.js +407 -115
- package/dist/policy.js +76 -0
- package/dist/prompts.js +44 -1
- package/dist/subprocess.js +40 -0
- package/dist/telemetry.js +31 -0
- package/package.json +10 -1
- package/prompts/implement.md +46 -2
- package/templates/Dockerfile.claude-code.base +24 -0
package/dist/git.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Data, Effect } from "effect";
|
|
2
|
+
import { runExecaScoped } from "./subprocess.js";
|
|
3
|
+
/**
|
|
4
|
+
* VA-358: thin typed error for `detectBaseBranch`. We don't model
|
|
5
|
+
* every git failure mode — the orchestrator just wants to know "did
|
|
6
|
+
* we get a branch name back?" If not, surface a single error so the
|
|
7
|
+
* caller can fail fast with a helpful message. As a `Data.TaggedError`
|
|
8
|
+
* it's an Error instance, so vitest's `.rejects.toThrow(/regex/)`
|
|
9
|
+
* works the same as before this refactor.
|
|
10
|
+
*/
|
|
11
|
+
export class BaseBranchDetectionFailed extends Data.TaggedError("BaseBranchDetectionFailed") {
|
|
12
|
+
}
|
|
2
13
|
/**
|
|
3
14
|
* Resolve the default branch name of the cwd repo. Tries
|
|
4
15
|
* `git symbolic-ref` against `origin/HEAD` first (fast, works on any
|
|
@@ -6,36 +17,39 @@ import { execa } from "execa";
|
|
|
6
17
|
* `git remote show origin` (slower, hits the network but works on
|
|
7
18
|
* fresh clones that never had `origin/HEAD` set locally).
|
|
8
19
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
20
|
+
* VA-358: now an Effect so the orchestrator program can stay in
|
|
21
|
+
* Effect-land end-to-end. Subprocess kills propagate via
|
|
22
|
+
* `runExecaScoped` if the orchestrator fiber is interrupted.
|
|
12
23
|
*/
|
|
13
|
-
export
|
|
14
|
-
// Fast path: local symbolic ref. Returns e.g. `origin/main
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
export const detectBaseBranch = (repoPath) => Effect.gen(function* () {
|
|
25
|
+
// Fast path: local symbolic ref. Returns e.g. `origin/main`.
|
|
26
|
+
const symbolic = yield* runExecaScoped("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: repoPath, reject: false }, (err) => new BaseBranchDetectionFailed({
|
|
27
|
+
message: err instanceof Error ? err.message : String(err),
|
|
28
|
+
})).pipe(Effect.either);
|
|
29
|
+
if (symbolic._tag === "Right" && symbolic.right.exitCode === 0) {
|
|
30
|
+
// execa's `stdout` type is a union (encoding-dependent). We don't
|
|
31
|
+
// change `encoding`, so it's `string` at runtime — narrow here
|
|
32
|
+
// rather than fighting the generic types upstream.
|
|
33
|
+
const raw = symbolic.right.stdout;
|
|
34
|
+
const out = typeof raw === "string" ? raw : "";
|
|
35
|
+
const name = out.trim().replace(/^origin\//, "");
|
|
36
|
+
if (name)
|
|
37
|
+
return name;
|
|
22
38
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const match = stdout.match(/^\s*HEAD branch:\s*(\S+)\s*$/m);
|
|
39
|
+
// Slow path: ask the remote. Output line: ` HEAD branch: master`.
|
|
40
|
+
const remoteShow = yield* runExecaScoped("git", ["remote", "show", "origin"], { cwd: repoPath }, (err) => new BaseBranchDetectionFailed({
|
|
41
|
+
message: err instanceof Error ? err.message : String(err),
|
|
42
|
+
})).pipe(Effect.either);
|
|
43
|
+
if (remoteShow._tag === "Right") {
|
|
44
|
+
const raw = remoteShow.right.stdout;
|
|
45
|
+
const out = typeof raw === "string" ? raw : "";
|
|
46
|
+
const match = out.match(/^\s*HEAD branch:\s*(\S+)\s*$/m);
|
|
32
47
|
if (match?.[1])
|
|
33
48
|
return match[1];
|
|
34
49
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
50
|
+
return yield* Effect.fail(new BaseBranchDetectionFailed({
|
|
51
|
+
message: `Could not detect the default branch of ${repoPath}. ` +
|
|
52
|
+
`Set RUNWAY_BASE_BRANCH explicitly, or run ` +
|
|
53
|
+
`\`git remote set-head origin --auto\` to populate origin/HEAD.`,
|
|
54
|
+
}));
|
|
55
|
+
});
|
package/dist/github.js
CHANGED
|
@@ -1,4 +1,70 @@
|
|
|
1
|
+
import { Data, Effect, Schedule } from "effect";
|
|
1
2
|
import { execa } from "execa";
|
|
3
|
+
// VA-356: typed error ADT for the GitHub gateway. `GhCliMissing` is
|
|
4
|
+
// its own branch so the orchestrator (and `runway doctor`) can show
|
|
5
|
+
// an install hint instead of an opaque ENOENT. Push and PR failures
|
|
6
|
+
// are split because retry semantics differ — re-pushing the same
|
|
7
|
+
// branch is idempotent, re-running `gh pr create` after a partial
|
|
8
|
+
// failure can create duplicate PRs.
|
|
9
|
+
export class GhCliMissing extends Data.TaggedError("GhCliMissing") {
|
|
10
|
+
}
|
|
11
|
+
export class PushFailed extends Data.TaggedError("PushFailed") {
|
|
12
|
+
}
|
|
13
|
+
export class PrCreateFailed extends Data.TaggedError("PrCreateFailed") {
|
|
14
|
+
}
|
|
15
|
+
// VA-357: a hung `gh` or `git` subprocess becomes a typed timeout.
|
|
16
|
+
// Step 3 (VA-358) is where we add scoped subprocess cleanup; here the
|
|
17
|
+
// Effect's fiber gets interrupted but the underlying child may still
|
|
18
|
+
// be running. We accept that limitation for Step 2.
|
|
19
|
+
export class GithubTimeout extends Data.TaggedError("GithubTimeout") {
|
|
20
|
+
}
|
|
21
|
+
// VA-357: same jittered exponential shape as the Linear policy.
|
|
22
|
+
export const githubRetrySchedule = Schedule.exponential("1 second").pipe(Schedule.compose(Schedule.recurs(5)), Schedule.jittered);
|
|
23
|
+
/**
|
|
24
|
+
* VA-357: timeout + optional retry policy for a GitHub call. Unlike
|
|
25
|
+
* Linear (where all methods are idempotent reads or idempotent
|
|
26
|
+
* updates), GitHub differs by method:
|
|
27
|
+
*
|
|
28
|
+
* - `push` is idempotent (re-pushing the same SHA is a no-op) ⇒ retry
|
|
29
|
+
* on timeout.
|
|
30
|
+
* - `gh pr create` is NOT idempotent — a retry after a partial failure
|
|
31
|
+
* can create a duplicate PR. Just bound it with a timeout, no
|
|
32
|
+
* retries.
|
|
33
|
+
*
|
|
34
|
+
* The caller picks the policy by passing a `retryOn` predicate (or
|
|
35
|
+
* omitting it for timeout-only).
|
|
36
|
+
*/
|
|
37
|
+
export const applyGithubPolicy = (effect, opts) => {
|
|
38
|
+
const withTimeout = effect.pipe(Effect.timeoutFail({
|
|
39
|
+
duration: `${opts.timeoutMs} millis`,
|
|
40
|
+
onTimeout: () => new GithubTimeout({
|
|
41
|
+
call: opts.call,
|
|
42
|
+
afterMs: opts.timeoutMs,
|
|
43
|
+
message: `${opts.call} timed out after ${opts.timeoutMs}ms`,
|
|
44
|
+
}),
|
|
45
|
+
}));
|
|
46
|
+
if (!opts.retryOn)
|
|
47
|
+
return withTimeout;
|
|
48
|
+
return withTimeout.pipe(Effect.retry({
|
|
49
|
+
schedule: githubRetrySchedule,
|
|
50
|
+
while: opts.retryOn,
|
|
51
|
+
}));
|
|
52
|
+
};
|
|
53
|
+
function isCommandMissing(err) {
|
|
54
|
+
if (!(err instanceof Error))
|
|
55
|
+
return false;
|
|
56
|
+
const e = err;
|
|
57
|
+
if (e.code === "ENOENT")
|
|
58
|
+
return true;
|
|
59
|
+
return /\bcommand not found\b|: not found/i.test(e.message);
|
|
60
|
+
}
|
|
61
|
+
function execaStderr(err) {
|
|
62
|
+
if (err && typeof err === "object" && "stderr" in err) {
|
|
63
|
+
const s = err.stderr;
|
|
64
|
+
return typeof s === "string" ? s : "";
|
|
65
|
+
}
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
2
68
|
/**
|
|
3
69
|
* `gh` CLI-backed gateway. Runway runs on a host with `gh` authenticated
|
|
4
70
|
* (via `GH_TOKEN` or the user's keychain login); we don't reimplement
|
|
@@ -6,29 +72,78 @@ import { execa } from "execa";
|
|
|
6
72
|
*/
|
|
7
73
|
export function createGithubGateway() {
|
|
8
74
|
return {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
75
|
+
pushBranch(repoPath, branch) {
|
|
76
|
+
return applyGithubPolicy(Effect.tryPromise({
|
|
77
|
+
try: async () => {
|
|
78
|
+
await execa("git", ["push", "-u", "origin", branch], {
|
|
79
|
+
cwd: repoPath,
|
|
80
|
+
stdio: "inherit",
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
catch: (err) => {
|
|
84
|
+
if (isCommandMissing(err)) {
|
|
85
|
+
return new GhCliMissing({
|
|
86
|
+
message: `git not found on PATH: ${err instanceof Error ? err.message : String(err)}`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return new PushFailed({
|
|
90
|
+
branch,
|
|
91
|
+
stderr: execaStderr(err),
|
|
92
|
+
message: err instanceof Error
|
|
93
|
+
? err.message
|
|
94
|
+
: `push failed: ${String(err)}`,
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
}), {
|
|
98
|
+
call: `pushBranch(${branch})`,
|
|
99
|
+
// `git push` can be slow for large branches on slow networks.
|
|
100
|
+
timeoutMs: 60_000,
|
|
101
|
+
// Push is idempotent; retry on timeout only. We don't retry
|
|
102
|
+
// PushFailed because a real failure (auth, conflict) won't
|
|
103
|
+
// resolve itself.
|
|
104
|
+
retryOn: (err) => err._tag === "GithubTimeout",
|
|
13
105
|
});
|
|
14
106
|
},
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
107
|
+
openPullRequest({ repoPath, branch, base, issue, body }) {
|
|
108
|
+
return applyGithubPolicy(Effect.tryPromise({
|
|
109
|
+
try: async () => {
|
|
110
|
+
const title = `${issue.identifier}: ${issue.title}`;
|
|
111
|
+
const { stdout } = await execa("gh", [
|
|
112
|
+
"pr",
|
|
113
|
+
"create",
|
|
114
|
+
"--base",
|
|
115
|
+
base,
|
|
116
|
+
"--head",
|
|
117
|
+
branch,
|
|
118
|
+
"--title",
|
|
119
|
+
title,
|
|
120
|
+
"--body",
|
|
121
|
+
body,
|
|
122
|
+
], { cwd: repoPath });
|
|
123
|
+
// `gh pr create` prints the URL on the last line.
|
|
124
|
+
return stdout.trim().split("\n").at(-1) ?? "";
|
|
125
|
+
},
|
|
126
|
+
catch: (err) => {
|
|
127
|
+
if (isCommandMissing(err)) {
|
|
128
|
+
return new GhCliMissing({
|
|
129
|
+
message: `gh CLI not found on PATH — install https://cli.github.com (${err instanceof Error ? err.message : String(err)})`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return new PrCreateFailed({
|
|
133
|
+
branch,
|
|
134
|
+
stderr: execaStderr(err),
|
|
135
|
+
message: err instanceof Error
|
|
136
|
+
? err.message
|
|
137
|
+
: `gh pr create failed: ${String(err)}`,
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
}), {
|
|
141
|
+
call: `openPullRequest(${branch})`,
|
|
142
|
+
timeoutMs: 30_000,
|
|
143
|
+
// No retry: gh pr create is NOT idempotent — a partial
|
|
144
|
+
// success on the previous attempt could leave a PR behind,
|
|
145
|
+
// and a retry would create a duplicate.
|
|
146
|
+
});
|
|
32
147
|
},
|
|
33
148
|
};
|
|
34
149
|
}
|
package/dist/linear.js
CHANGED
|
@@ -1,29 +1,177 @@
|
|
|
1
1
|
import { LinearClient } from "@linear/sdk";
|
|
2
|
+
import { Data, Effect, ParseResult, Redacted, Schedule, Schema, } from "effect";
|
|
3
|
+
// VA-360: schemas covering only the fields runway actually reads
|
|
4
|
+
// off the Linear SDK. Validation runs at the gateway boundary so an
|
|
5
|
+
// upstream API drift (renamed field, removed field, type change)
|
|
6
|
+
// surfaces as a typed `LinearSchemaError` rather than a `Cannot read
|
|
7
|
+
// property 'nodes' of undefined` deep in the call stack.
|
|
8
|
+
const IssueNodeSchema = Schema.Struct({
|
|
9
|
+
id: Schema.String,
|
|
10
|
+
identifier: Schema.String,
|
|
11
|
+
title: Schema.String,
|
|
12
|
+
description: Schema.NullOr(Schema.String),
|
|
13
|
+
});
|
|
14
|
+
const WorkflowStateNodeSchema = Schema.Struct({
|
|
15
|
+
id: Schema.String,
|
|
16
|
+
name: Schema.String,
|
|
17
|
+
});
|
|
18
|
+
const TeamNodeSchema = Schema.Struct({
|
|
19
|
+
id: Schema.String,
|
|
20
|
+
key: Schema.String,
|
|
21
|
+
});
|
|
22
|
+
const IssueLabelNodeSchema = Schema.Struct({
|
|
23
|
+
id: Schema.String,
|
|
24
|
+
name: Schema.String,
|
|
25
|
+
});
|
|
26
|
+
const ProjectNodeSchema = Schema.Struct({
|
|
27
|
+
id: Schema.String,
|
|
28
|
+
});
|
|
29
|
+
const decodeIssueNode = Schema.decodeUnknownSync(IssueNodeSchema);
|
|
30
|
+
const decodeWorkflowStateNode = Schema.decodeUnknownSync(WorkflowStateNodeSchema);
|
|
31
|
+
const decodeTeamNode = Schema.decodeUnknownSync(TeamNodeSchema);
|
|
32
|
+
const decodeIssueLabelNode = Schema.decodeUnknownSync(IssueLabelNodeSchema);
|
|
33
|
+
const decodeProjectNode = Schema.decodeUnknownSync(ProjectNodeSchema);
|
|
34
|
+
export class LinearNotFound extends Data.TaggedError("LinearNotFound") {
|
|
35
|
+
}
|
|
36
|
+
export class LinearUnauthorized extends Data.TaggedError("LinearUnauthorized") {
|
|
37
|
+
}
|
|
38
|
+
export class LinearRateLimited extends Data.TaggedError("LinearRateLimited") {
|
|
39
|
+
}
|
|
40
|
+
export class LinearNetworkError extends Data.TaggedError("LinearNetworkError") {
|
|
41
|
+
}
|
|
42
|
+
// VA-357: a stalled HTTP request becomes a typed timeout (rather than
|
|
43
|
+
// a stuck process). Retryable just like NetworkError — the next attempt
|
|
44
|
+
// may succeed.
|
|
45
|
+
export class LinearTimeout extends Data.TaggedError("LinearTimeout") {
|
|
46
|
+
}
|
|
47
|
+
// VA-360: SDK response failed schema validation — Linear's GraphQL
|
|
48
|
+
// schema drifted, an expected field went missing, or a type changed.
|
|
49
|
+
// NOT retryable: re-running the same call will produce the same
|
|
50
|
+
// drift. The operator needs to update runway's schemas (or pin the
|
|
51
|
+
// SDK version) before this gets better.
|
|
52
|
+
export class LinearSchemaError extends Data.TaggedError("LinearSchemaError") {
|
|
53
|
+
}
|
|
54
|
+
// VA-357: which typed errors a retry policy will re-attempt. Hard
|
|
55
|
+
// failures (NotFound, Unauthorized) bypass the retry loop and surface
|
|
56
|
+
// immediately — they are operator-fix problems, not transient blips.
|
|
57
|
+
const RETRYABLE_LINEAR_TAGS = new Set([
|
|
58
|
+
"LinearNetworkError",
|
|
59
|
+
"LinearRateLimited",
|
|
60
|
+
"LinearTimeout",
|
|
61
|
+
]);
|
|
62
|
+
const isRetryableLinearError = (err) => RETRYABLE_LINEAR_TAGS.has(err._tag);
|
|
63
|
+
/**
|
|
64
|
+
* VA-357: timeout + jittered-exponential retry policy for one Linear
|
|
65
|
+
* call. 5 retries from a 1s base ⇒ delays jittered around 1/2/4/8/16s
|
|
66
|
+
* ⇒ ~31s worst-case wall time before bubbling out. `while` gates the
|
|
67
|
+
* retry on the typed error tag so an `Unauthorized` or `NotFound` is
|
|
68
|
+
* not retried (no point: it's an operator/config problem).
|
|
69
|
+
*
|
|
70
|
+
* Exported so unit tests can apply it to a hand-built Effect and
|
|
71
|
+
* verify retry behavior under `TestClock`.
|
|
72
|
+
*/
|
|
73
|
+
export const linearRetrySchedule = Schedule.exponential("1 second").pipe(Schedule.compose(Schedule.recurs(5)), Schedule.jittered);
|
|
74
|
+
export const applyLinearPolicy = (effect, opts) => {
|
|
75
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
76
|
+
return effect.pipe(Effect.timeoutFail({
|
|
77
|
+
duration: `${timeoutMs} millis`,
|
|
78
|
+
onTimeout: () => new LinearTimeout({
|
|
79
|
+
call: opts.call,
|
|
80
|
+
afterMs: timeoutMs,
|
|
81
|
+
message: `Linear ${opts.call} timed out after ${timeoutMs}ms`,
|
|
82
|
+
}),
|
|
83
|
+
}), Effect.retry({
|
|
84
|
+
schedule: linearRetrySchedule,
|
|
85
|
+
while: isRetryableLinearError,
|
|
86
|
+
}));
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Best-effort classification of an unknown thrown value into the
|
|
90
|
+
* gateway's typed error ADT. `LinearNotFound`s we threw ourselves
|
|
91
|
+
* (from the find* helpers) pass through unchanged; SDK / network
|
|
92
|
+
* exceptions are bucketed by message-shape. Step 2 (VA-357) is where
|
|
93
|
+
* the retry policy actually leans on these tags — for now, the value
|
|
94
|
+
* is mostly that the orchestrator's `catch (err: unknown)` becomes
|
|
95
|
+
* `catch (err: LinearError)`.
|
|
96
|
+
*/
|
|
97
|
+
function classifyLinearError(err, context) {
|
|
98
|
+
if (err instanceof LinearNotFound ||
|
|
99
|
+
err instanceof LinearUnauthorized ||
|
|
100
|
+
err instanceof LinearRateLimited ||
|
|
101
|
+
err instanceof LinearNetworkError ||
|
|
102
|
+
err instanceof LinearTimeout ||
|
|
103
|
+
err instanceof LinearSchemaError) {
|
|
104
|
+
return err;
|
|
105
|
+
}
|
|
106
|
+
// VA-360: a schema validation throw (Linear API drift) should
|
|
107
|
+
// surface as `LinearSchemaError`, not get bucketed as a transient
|
|
108
|
+
// NetworkError — retrying won't fix a drifted field.
|
|
109
|
+
if (ParseResult.isParseError(err)) {
|
|
110
|
+
return new LinearSchemaError({
|
|
111
|
+
call: context,
|
|
112
|
+
message: `${context}: SDK response failed schema validation — ${err.message}`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
116
|
+
if (/\b(401|403)\b|unauthorized|invalid api key|forbidden/i.test(msg)) {
|
|
117
|
+
return new LinearUnauthorized({ message: msg });
|
|
118
|
+
}
|
|
119
|
+
if (/\b429\b|rate.?limit/i.test(msg)) {
|
|
120
|
+
return new LinearRateLimited({ message: msg });
|
|
121
|
+
}
|
|
122
|
+
return new LinearNetworkError({
|
|
123
|
+
cause: err,
|
|
124
|
+
message: `${context}: ${msg}`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
2
127
|
/**
|
|
3
128
|
* Concrete @linear/sdk-backed implementation. Tests inject their own
|
|
4
129
|
* gateway; never reach for the SDK directly outside this file.
|
|
130
|
+
*
|
|
131
|
+
* The internal `find*` helpers stay plain `async` and throw typed
|
|
132
|
+
* `LinearNotFound` directly. Only the public `LinearGateway` methods
|
|
133
|
+
* wrap into `Effect.tryPromise` — that's the boundary the orchestrator
|
|
134
|
+
* sees.
|
|
135
|
+
*
|
|
136
|
+
* VA-358: optional `limiter` wraps every method's Effect so the queue
|
|
137
|
+
* drain respects Linear's API rate limit (~1500 req/hr). When `null`
|
|
138
|
+
* (the default — tests don't need it), calls go through unrestricted.
|
|
139
|
+
* Production: `commands/run.ts` builds a limiter inside `Effect.scoped`
|
|
140
|
+
* and passes it here.
|
|
5
141
|
*/
|
|
6
|
-
export function createLinearGateway(config) {
|
|
7
|
-
const client = new LinearClient({ apiKey: config.linearApiKey });
|
|
142
|
+
export function createLinearGateway(config, limiter = null) {
|
|
143
|
+
const client = new LinearClient({ apiKey: Redacted.value(config.linearApiKey) });
|
|
144
|
+
const gate = (eff) => (limiter ? limiter(eff) : eff);
|
|
8
145
|
async function findStateId(teamId, name) {
|
|
9
146
|
const states = await client.workflowStates({
|
|
10
147
|
filter: { team: { id: { eq: teamId } }, name: { eq: name } },
|
|
11
148
|
});
|
|
12
|
-
const
|
|
13
|
-
if (!
|
|
14
|
-
throw new
|
|
149
|
+
const rawState = states.nodes[0];
|
|
150
|
+
if (!rawState) {
|
|
151
|
+
throw new LinearNotFound({
|
|
152
|
+
resource: "state",
|
|
153
|
+
identifier: name,
|
|
154
|
+
message: `Linear workflow state "${name}" not found on team ${teamId}`,
|
|
155
|
+
});
|
|
15
156
|
}
|
|
16
|
-
|
|
157
|
+
// VA-360: validate the SDK shape. `decodeWorkflowStateNode` throws
|
|
158
|
+
// `ParseError` on drift, which `classifyLinearError` maps to
|
|
159
|
+
// `LinearSchemaError` (non-retryable).
|
|
160
|
+
return decodeWorkflowStateNode(rawState).id;
|
|
17
161
|
}
|
|
18
162
|
async function findTeamId() {
|
|
19
163
|
const teams = await client.teams({
|
|
20
164
|
filter: { key: { eq: config.linearTeam } },
|
|
21
165
|
});
|
|
22
|
-
const
|
|
23
|
-
if (!
|
|
24
|
-
throw new
|
|
166
|
+
const rawTeam = teams.nodes[0];
|
|
167
|
+
if (!rawTeam) {
|
|
168
|
+
throw new LinearNotFound({
|
|
169
|
+
resource: "team",
|
|
170
|
+
identifier: config.linearTeam,
|
|
171
|
+
message: `Linear team "${config.linearTeam}" not found`,
|
|
172
|
+
});
|
|
25
173
|
}
|
|
26
|
-
return
|
|
174
|
+
return decodeTeamNode(rawTeam).id;
|
|
27
175
|
}
|
|
28
176
|
/**
|
|
29
177
|
* Resolve a project identifier (UUID, slug, or name) to its Linear
|
|
@@ -40,67 +188,151 @@ export function createLinearGateway(config) {
|
|
|
40
188
|
],
|
|
41
189
|
},
|
|
42
190
|
});
|
|
43
|
-
const
|
|
44
|
-
if (!
|
|
45
|
-
throw new
|
|
191
|
+
const rawProject = projects.nodes[0];
|
|
192
|
+
if (!rawProject) {
|
|
193
|
+
throw new LinearNotFound({
|
|
194
|
+
resource: "project",
|
|
195
|
+
identifier,
|
|
196
|
+
message: `Linear project "${identifier}" not found`,
|
|
197
|
+
});
|
|
46
198
|
}
|
|
47
|
-
return
|
|
199
|
+
return decodeProjectNode(rawProject).id;
|
|
48
200
|
}
|
|
49
201
|
return {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
202
|
+
fetchReady() {
|
|
203
|
+
return gate(applyLinearPolicy(Effect.tryPromise({
|
|
204
|
+
try: async () => {
|
|
205
|
+
const teamId = await findTeamId();
|
|
206
|
+
const readyStateId = await findStateId(teamId, config.readyStatus);
|
|
207
|
+
const projectId = config.linearProject
|
|
208
|
+
? await findProjectId(config.linearProject)
|
|
209
|
+
: null;
|
|
210
|
+
const issues = await client.issues({
|
|
211
|
+
filter: {
|
|
212
|
+
team: { id: { eq: teamId } },
|
|
213
|
+
state: { id: { eq: readyStateId } },
|
|
214
|
+
...(projectId ? { project: { id: { eq: projectId } } } : {}),
|
|
215
|
+
},
|
|
216
|
+
// Stable order: oldest first so the queue drains FIFO.
|
|
217
|
+
orderBy: "createdAt",
|
|
218
|
+
});
|
|
219
|
+
// VA-360: validate every issue node through the schema —
|
|
220
|
+
// a single drifted issue surfaces as `LinearSchemaError`
|
|
221
|
+
// instead of a downstream `cannot read property X`.
|
|
222
|
+
return issues.nodes.map((raw) => {
|
|
223
|
+
const i = decodeIssueNode(raw);
|
|
224
|
+
return {
|
|
225
|
+
id: i.id,
|
|
226
|
+
identifier: i.identifier,
|
|
227
|
+
title: i.title,
|
|
228
|
+
description: i.description ?? "",
|
|
229
|
+
};
|
|
230
|
+
});
|
|
61
231
|
},
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
});
|
|
65
|
-
return issues.nodes.map((i) => ({
|
|
66
|
-
id: i.id,
|
|
67
|
-
identifier: i.identifier,
|
|
68
|
-
title: i.title,
|
|
69
|
-
description: i.description ?? "",
|
|
70
|
-
}));
|
|
232
|
+
catch: (err) => classifyLinearError(err, "fetchReady"),
|
|
233
|
+
}), { call: "fetchReady" }));
|
|
71
234
|
},
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
235
|
+
transition(issueId, statusName) {
|
|
236
|
+
return gate(applyLinearPolicy(Effect.tryPromise({
|
|
237
|
+
try: async () => {
|
|
238
|
+
const issue = await client.issue(issueId);
|
|
239
|
+
const team = await issue.team;
|
|
240
|
+
if (!team) {
|
|
241
|
+
throw new LinearNotFound({
|
|
242
|
+
resource: "issue",
|
|
243
|
+
identifier: issueId,
|
|
244
|
+
message: `Issue ${issueId} has no team`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
const stateId = await findStateId(team.id, statusName);
|
|
248
|
+
await client.updateIssue(issueId, { stateId });
|
|
249
|
+
},
|
|
250
|
+
catch: (err) => classifyLinearError(err, `transition(${statusName})`),
|
|
251
|
+
}), { call: `transition(${statusName})` }));
|
|
79
252
|
},
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
253
|
+
applyLabel(issueId, labelName) {
|
|
254
|
+
return gate(applyLinearPolicy(Effect.tryPromise({
|
|
255
|
+
try: async () => {
|
|
256
|
+
const issue = await client.issue(issueId);
|
|
257
|
+
const team = await issue.team;
|
|
258
|
+
if (!team) {
|
|
259
|
+
throw new LinearNotFound({
|
|
260
|
+
resource: "issue",
|
|
261
|
+
identifier: issueId,
|
|
262
|
+
message: `Issue ${issueId} has no team`,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const labels = await client.issueLabels({
|
|
266
|
+
filter: {
|
|
267
|
+
team: { id: { eq: team.id } },
|
|
268
|
+
name: { eq: labelName },
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
const rawLabel = labels.nodes[0];
|
|
272
|
+
if (!rawLabel) {
|
|
273
|
+
throw new LinearNotFound({
|
|
274
|
+
resource: "label",
|
|
275
|
+
identifier: labelName,
|
|
276
|
+
message: `Linear label "${labelName}" not found on team ${team.id}`,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const label = decodeIssueLabelNode(rawLabel);
|
|
280
|
+
const existing = await issue.labels();
|
|
281
|
+
const existingIds = existing.nodes.map((l) => decodeIssueLabelNode(l).id);
|
|
282
|
+
const labelIds = [...existingIds, label.id];
|
|
283
|
+
await client.updateIssue(issueId, { labelIds });
|
|
89
284
|
},
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (!label) {
|
|
93
|
-
throw new Error(`Linear label "${labelName}" not found on team ${team.id}`);
|
|
94
|
-
}
|
|
95
|
-
const existing = await issue.labels();
|
|
96
|
-
const labelIds = [
|
|
97
|
-
...existing.nodes.map((l) => l.id),
|
|
98
|
-
label.id,
|
|
99
|
-
];
|
|
100
|
-
await client.updateIssue(issueId, { labelIds });
|
|
285
|
+
catch: (err) => classifyLinearError(err, `applyLabel(${labelName})`),
|
|
286
|
+
}), { call: `applyLabel(${labelName})` }));
|
|
101
287
|
},
|
|
102
|
-
|
|
103
|
-
|
|
288
|
+
comment(issueId, body) {
|
|
289
|
+
return gate(applyLinearPolicy(Effect.tryPromise({
|
|
290
|
+
try: async () => {
|
|
291
|
+
await client.createComment({ issueId, body });
|
|
292
|
+
},
|
|
293
|
+
catch: (err) => classifyLinearError(err, "comment"),
|
|
294
|
+
}), { call: "comment" }));
|
|
104
295
|
},
|
|
105
296
|
};
|
|
106
297
|
}
|
|
298
|
+
export async function validateLinearConfig(config) {
|
|
299
|
+
const client = new LinearClient({ apiKey: Redacted.value(config.linearApiKey) });
|
|
300
|
+
const teams = await client.teams({
|
|
301
|
+
filter: { key: { eq: config.linearTeam } },
|
|
302
|
+
});
|
|
303
|
+
const team = teams.nodes[0];
|
|
304
|
+
if (!team) {
|
|
305
|
+
return {
|
|
306
|
+
team: { kind: "missing", key: config.linearTeam },
|
|
307
|
+
readyStatus: { kind: "skipped", reason: "team missing" },
|
|
308
|
+
inProgressStatus: { kind: "skipped", reason: "team missing" },
|
|
309
|
+
inReviewStatus: { kind: "skipped", reason: "team missing" },
|
|
310
|
+
hitlLabel: { kind: "skipped", reason: "team missing" },
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const states = await client.workflowStates({
|
|
314
|
+
filter: { team: { id: { eq: team.id } } },
|
|
315
|
+
});
|
|
316
|
+
const stateNames = states.nodes.map((s) => s.name);
|
|
317
|
+
const checkState = (want) => stateNames.includes(want)
|
|
318
|
+
? { kind: "ok", name: want }
|
|
319
|
+
: { kind: "missing", name: want, available: stateNames };
|
|
320
|
+
const labels = await client.issueLabels({
|
|
321
|
+
filter: { team: { id: { eq: team.id } } },
|
|
322
|
+
});
|
|
323
|
+
const labelNames = labels.nodes.map((l) => l.name);
|
|
324
|
+
const hitlLabel = labelNames.includes(config.hitlLabel)
|
|
325
|
+
? { kind: "ok", name: config.hitlLabel }
|
|
326
|
+
: {
|
|
327
|
+
kind: "missing",
|
|
328
|
+
name: config.hitlLabel,
|
|
329
|
+
available: labelNames.slice(0, 50),
|
|
330
|
+
};
|
|
331
|
+
return {
|
|
332
|
+
team: { kind: "ok", id: team.id },
|
|
333
|
+
readyStatus: checkState(config.readyStatus),
|
|
334
|
+
inProgressStatus: checkState(config.inProgressStatus),
|
|
335
|
+
inReviewStatus: checkState(config.inReviewStatus),
|
|
336
|
+
hitlLabel,
|
|
337
|
+
};
|
|
338
|
+
}
|