@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/dist/git.js CHANGED
@@ -1,4 +1,15 @@
1
- import { execa } from "execa";
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
- * Throws if neither path resolves a branch name better to fail
10
- * fast at orchestrator startup than to crash mid-diff with a stale
11
- * "ambiguous argument" git error.
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 async function detectBaseBranch(repoPath) {
14
- // Fast path: local symbolic ref. Returns e.g. `origin/main` or `origin/master`.
15
- try {
16
- const { stdout, exitCode } = await execa("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: repoPath, reject: false });
17
- if (exitCode === 0) {
18
- const name = stdout.trim().replace(/^origin\//, "");
19
- if (name)
20
- return name;
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
- catch {
24
- // fall through to remote-show fallback
25
- }
26
- // Slow path: ask the remote. Output line looks like ` HEAD branch: master`.
27
- try {
28
- const { stdout } = await execa("git", ["remote", "show", "origin"], {
29
- cwd: repoPath,
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
- catch {
36
- // fall through to error
37
- }
38
- throw new Error(`Could not detect the default branch of ${repoPath}. ` +
39
- `Set RUNWAY_BASE_BRANCH explicitly, or run ` +
40
- `\`git remote set-head origin --auto\` to populate origin/HEAD.`);
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
- async pushBranch(repoPath, branch) {
10
- await execa("git", ["push", "-u", "origin", branch], {
11
- cwd: repoPath,
12
- stdio: "inherit",
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
- async openPullRequest({ repoPath, branch, base, issue, body }) {
16
- const title = `${issue.identifier}: ${issue.title}`;
17
- const { stdout } = await execa("gh", [
18
- "pr",
19
- "create",
20
- "--base",
21
- base,
22
- "--head",
23
- branch,
24
- "--title",
25
- title,
26
- "--body",
27
- body,
28
- ], { cwd: repoPath });
29
- // `gh pr create` prints the URL on the last line.
30
- const url = stdout.trim().split("\n").at(-1) ?? "";
31
- return url;
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 state = states.nodes[0];
13
- if (!state) {
14
- throw new Error(`Linear workflow state "${name}" not found on team ${teamId}`);
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
- return state.id;
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 team = teams.nodes[0];
23
- if (!team) {
24
- throw new Error(`Linear team "${config.linearTeam}" not found`);
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 team.id;
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 project = projects.nodes[0];
44
- if (!project) {
45
- throw new Error(`Linear project "${identifier}" not found`);
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 project.id;
199
+ return decodeProjectNode(rawProject).id;
48
200
  }
49
201
  return {
50
- async fetchReady() {
51
- const teamId = await findTeamId();
52
- const readyStateId = await findStateId(teamId, config.readyStatus);
53
- const projectId = config.linearProject
54
- ? await findProjectId(config.linearProject)
55
- : null;
56
- const issues = await client.issues({
57
- filter: {
58
- team: { id: { eq: teamId } },
59
- state: { id: { eq: readyStateId } },
60
- ...(projectId ? { project: { id: { eq: projectId } } } : {}),
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
- // Stable order: oldest first so the queue drains FIFO.
63
- orderBy: "createdAt",
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
- async transition(issueId, statusName) {
73
- const issue = await client.issue(issueId);
74
- const team = await issue.team;
75
- if (!team)
76
- throw new Error(`Issue ${issueId} has no team`);
77
- const stateId = await findStateId(team.id, statusName);
78
- await client.updateIssue(issueId, { stateId });
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
- async applyLabel(issueId, labelName) {
81
- const issue = await client.issue(issueId);
82
- const team = await issue.team;
83
- if (!team)
84
- throw new Error(`Issue ${issueId} has no team`);
85
- const labels = await client.issueLabels({
86
- filter: {
87
- team: { id: { eq: team.id } },
88
- name: { eq: labelName },
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
- const label = labels.nodes[0];
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
- async comment(issueId, body) {
103
- await client.createComment({ issueId, body });
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
+ }