Skip to content

Commit 6ecd828

Browse files
authored
fix(connectors): gh PATH resolution + Linear actions (#261)
* fix(connectors): resolve gh/git via login-shell PATH in packaged builds Packaged Electron on macOS doesn't inherit the login-shell PATH, so Homebrew-installed `gh` at /opt/homebrew/bin wasn't visible to spawn and surfaced as the cryptic "spawn gh ENOENT" in Settings → Connectors. - Add resolveExecutable() that searches PATH from getSafeEnv() (login shell on macOS/Linux, process env on Windows with .exe/.cmd suffixes). - GitHub connector + connector:status use the resolved path and pass env: getSafeEnv() so gh's subprocesses also inherit the right PATH. - connector:status returns distinct messages for "not installed" (with platform install command) vs "not signed in" (gh auth login hint). - Generalize the resolver to git too (git-utils, file-utils), falling back to the bare name so nothing regresses when resolution misses. - Banner UI preserves newlines and renders `backtick` spans as code. * feat(linear): add commentOnIssue, createIssue, closeIssue actions Linear was read-only (tasks + triggers). Add three basic actions so it can be used as a downstream in workflows like GitHub. - commentOnIssue: resolve issue UUID by identifier, commentCreate. - createIssue: resolve team UUID by key (defaults to connection team), issueCreate with title + optional description. - closeIssue: fetch issue's team, pick the lowest-position workflow state with type=completed ("Done" before "Merged"/"Released"), issueUpdate with that stateId. Registered under capabilities and manifest.actions with {{connectorItem.externalId}} placeholders so they slot into workflows the same way GitHub's actions do. * style: prettier format resolve-executable.ts * test: update github/linear connector tests for PATH + Linear actions - github-connector: gh path can now be absolute (resolveGhPath) so match by basename instead of exact "gh". Pass an explicit cursor to the prOpened poll test to stop it flaking once wall-clock passes the hardcoded created_at. - linear-connector: capabilities now include "actions"; add coverage for commentOnIssue, createIssue, closeIssue happy paths and validation. * fix(connectors): address Copilot review on PR #261 - gh env: stop stripping GH_TOKEN/GITHUB_TOKEN so token-based gh auth keeps working. New getGhEnv() starts from getSafeEnv() (login-shell PATH) and re-adds the gh auth env vars from process.env. - resolveExecutable: only cache successful lookups so a fresh gh/git install is picked up without an app restart. - runWithStdin: spawn() ignores `timeout`, so add an explicit 15s safety timer that SIGTERMs the child — a hung credential helper could previously block the poll path indefinitely. - Linear closeIssue: query workflowStates with orderBy: position and fetch up to 50 so the lowest-position completed state is reliably the "Done" one, even on teams with many post-done states. - connector:status: log the underlying error when gh auth status fails so non-auth failures aren't silently lost. * test: cover gh-cli, resolve-executable, and linear error branches CI's 80% patch-coverage gate was failing at 79% because the new install-hint / error helpers and a handful of action validation branches weren't exercised. - gh-cli: platform-specific install hints, GhNotFoundError shape, getGhEnv() preserves GH_TOKEN / GITHUB_TOKEN and starts from safe env (login-shell PATH). - resolve-executable: negative lookups re-probe (not cached), positive lookups cache, empty PATH → null, Windows .exe/.cmd suffix fallback, resetResolveCache by name and globally. - linear: explicit coverage for success=false returns on each mutation, "team not found", "no completed state", "identifier not found", and required-body validation for commentOnIssue.
1 parent 7963463 commit 6ecd828

12 files changed

Lines changed: 838 additions & 31 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { resolveExecutable } from '../resolve-executable'
2+
import { getSafeEnv } from '../process-utils'
3+
4+
export function resolveGhPath(): string | null {
5+
return resolveExecutable('gh')
6+
}
7+
8+
/**
9+
* Env for invoking `gh`. Starts from `getSafeEnv()` for the login-shell PATH
10+
* but re-adds `GH_TOKEN` / `GITHUB_TOKEN` from the raw process env — `gh`
11+
* supports non-interactive auth via those, and `getSafeEnv()` strips them by
12+
* default as a general precaution.
13+
*/
14+
export function getGhEnv(): Record<string, string> {
15+
const env = getSafeEnv()
16+
for (const key of ['GH_TOKEN', 'GITHUB_TOKEN']) {
17+
const val = process.env[key]
18+
if (val) env[key] = val
19+
}
20+
return env
21+
}
22+
23+
export function ghInstallHint(): string {
24+
switch (process.platform) {
25+
case 'darwin':
26+
return 'Install with Homebrew: `brew install gh`'
27+
case 'win32':
28+
return 'Install with winget: `winget install --id GitHub.cli` (or download from https://cli.github.com)'
29+
default:
30+
return 'Install from https://cli.github.com (Debian/Ubuntu: `sudo apt install gh`)'
31+
}
32+
}
33+
34+
export class GhNotFoundError extends Error {
35+
readonly code = 'GH_NOT_FOUND'
36+
constructor() {
37+
super(`GitHub CLI (gh) not found on PATH. ${ghInstallHint()}`)
38+
this.name = 'GhNotFoundError'
39+
}
40+
}

packages/server/src/connectors/github.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
TaskStatus
1010
} from '@vornrun/shared/types'
1111
import log from '../logger'
12+
import { resolveGhPath, GhNotFoundError, getGhEnv } from './gh-cli'
1213

1314
const execFileAsync = promisify(execFile)
1415

@@ -26,13 +27,17 @@ function isTransientErr(err: unknown): boolean {
2627
* so gh's non-zero exit reasons (e.g. rate limiting, auth) surface in logs.
2728
*/
2829
async function gh(args: string[], cwd?: string, input?: string): Promise<string> {
30+
const ghPath = resolveGhPath()
31+
if (!ghPath) throw new GhNotFoundError()
32+
const env = getGhEnv()
2933
const run = async () => {
3034
if (input !== undefined) {
31-
return runWithStdin(args, input, cwd)
35+
return runWithStdin(ghPath, args, input, cwd, env)
3236
}
33-
const { stdout } = await execFileAsync('gh', args, {
37+
const { stdout } = await execFileAsync(ghPath, args, {
3438
timeout: 15_000,
3539
maxBuffer: 10 * 1024 * 1024,
40+
env,
3641
...(cwd && { cwd })
3742
})
3843
return stdout
@@ -58,19 +63,47 @@ async function gh(args: string[], cwd?: string, input?: string): Promise<string>
5863

5964
/** Run `gh` feeding `input` on stdin. Used by `gh api --input -` paths so we
6065
* never interpolate untrusted body values into shell arguments. */
61-
function runWithStdin(args: string[], input: string, cwd?: string): Promise<string> {
66+
function runWithStdin(
67+
ghPath: string,
68+
args: string[],
69+
input: string,
70+
cwd: string | undefined,
71+
env: Record<string, string>
72+
): Promise<string> {
6273
return new Promise((resolve, reject) => {
63-
const child = spawn('gh', args, { cwd, timeout: 15_000 })
74+
// Note: child_process.spawn doesn't honor `timeout` (unlike execFile), so
75+
// we enforce it with an explicit timer. Without this, a `gh` subprocess
76+
// blocking on e.g. a hung credential helper would hang poll/exec forever.
77+
const child = spawn(ghPath, args, { cwd, env })
6478
let stdout = ''
6579
let stderr = ''
80+
let settled = false
81+
const timer = setTimeout(() => {
82+
if (settled) return
83+
settled = true
84+
try {
85+
child.kill('SIGTERM')
86+
} catch {
87+
/* already exited */
88+
}
89+
reject(new Error('gh command timed out after 15s'))
90+
}, 15_000)
6691
child.stdout.on('data', (d) => {
6792
stdout += d.toString()
6893
})
6994
child.stderr.on('data', (d) => {
7095
stderr += d.toString()
7196
})
72-
child.on('error', reject)
97+
child.on('error', (err) => {
98+
if (settled) return
99+
settled = true
100+
clearTimeout(timer)
101+
reject(err)
102+
})
73103
child.on('close', (code) => {
104+
if (settled) return
105+
settled = true
106+
clearTimeout(timer)
74107
if (code === 0) return resolve(stdout)
75108
reject(new Error(`gh exited with code ${code}: ${stderr.trim() || 'no stderr'}`))
76109
})

packages/server/src/connectors/linear.ts

Lines changed: 235 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,70 @@ const ISSUE_FIELDS = `
7878
team { key }
7979
`
8080

81+
async function resolveIssueId(apiKey: string, identifier: string): Promise<string | null> {
82+
const data = await linearGraphQL<{ issues: { nodes: Array<{ id: string }> } }>(
83+
apiKey,
84+
`query IssueIdByIdentifier($identifier: String!) {
85+
issues(filter: { identifier: { eq: $identifier } }, first: 1) { nodes { id } }
86+
}`,
87+
{ identifier }
88+
)
89+
return data.issues.nodes[0]?.id ?? null
90+
}
91+
92+
async function resolveIssueWithTeam(
93+
apiKey: string,
94+
identifier: string
95+
): Promise<{ id: string; teamId: string; teamKey: string } | null> {
96+
const data = await linearGraphQL<{
97+
issues: { nodes: Array<{ id: string; team: { id: string; key: string } }> }
98+
}>(
99+
apiKey,
100+
`query IssueWithTeam($identifier: String!) {
101+
issues(filter: { identifier: { eq: $identifier } }, first: 1) {
102+
nodes { id team { id key } }
103+
}
104+
}`,
105+
{ identifier }
106+
)
107+
const node = data.issues.nodes[0]
108+
return node ? { id: node.id, teamId: node.team.id, teamKey: node.team.key } : null
109+
}
110+
111+
async function resolveTeamId(apiKey: string, teamKey: string): Promise<string | null> {
112+
const data = await linearGraphQL<{ teams: { nodes: Array<{ id: string }> } }>(
113+
apiKey,
114+
`query TeamIdByKey($key: String!) {
115+
teams(filter: { key: { eq: $key } }, first: 1) { nodes { id } }
116+
}`,
117+
{ key: teamKey }
118+
)
119+
return data.teams.nodes[0]?.id ?? null
120+
}
121+
122+
async function resolveCompletedStateId(apiKey: string, teamId: string): Promise<string | null> {
123+
// orderBy: position so the lowest-position completed state ("Done" in
124+
// Linear's defaults) is at index 0. Fetch a generous page so we don't miss
125+
// it if a team has many post-done states (Merged, Released, Shipped, etc.).
126+
const data = await linearGraphQL<{
127+
workflowStates: { nodes: Array<{ id: string; type: string; position: number }> }
128+
}>(
129+
apiKey,
130+
`query CompletedStates($teamId: ID!) {
131+
workflowStates(
132+
filter: { team: { id: { eq: $teamId } }, type: { eq: "completed" } }
133+
orderBy: position
134+
first: 50
135+
) { nodes { id type position } }
136+
}`,
137+
{ teamId }
138+
)
139+
const nodes = data.workflowStates.nodes
140+
if (!nodes.length) return null
141+
// Defensive client-side sort in case the server ever returns out of order.
142+
return nodes.slice().sort((a, b) => a.position - b.position)[0].id
143+
}
144+
81145
function requireAuth(filters: Record<string, unknown>): string {
82146
const key = filters.apiKey
83147
if (typeof key !== 'string' || !key.trim()) {
@@ -92,7 +156,7 @@ export const linearConnector: VornConnector = {
92156
id: 'linear',
93157
name: 'Linear',
94158
icon: 'linear',
95-
capabilities: ['tasks', 'triggers'],
159+
capabilities: ['tasks', 'triggers', 'actions'],
96160

97161
async listItems(filters: Record<string, unknown>): Promise<ExternalItem[]> {
98162
const apiKey = requireAuth(filters)
@@ -189,10 +253,107 @@ export const linearConnector: VornConnector = {
189253
}
190254
},
191255

192-
async execute(actionType: string, _args: Record<string, unknown>): Promise<ActionResult> {
193-
// Linear actions (createIssue, updateIssue) not yet implemented — the
194-
// connector is read-only for polling tasks into the board.
195-
return { success: false, error: `Linear action "${actionType}" not implemented yet` }
256+
async execute(actionType: string, args: Record<string, unknown>): Promise<ActionResult> {
257+
const apiKey = requireAuth(args)
258+
259+
switch (actionType) {
260+
case 'commentOnIssue': {
261+
const identifier = typeof args.identifier === 'string' ? args.identifier : ''
262+
const body = typeof args.body === 'string' ? args.body : ''
263+
if (!identifier) return { success: false, error: 'identifier is required (e.g. ENG-123)' }
264+
if (!body) return { success: false, error: 'body is required' }
265+
const issueId = await resolveIssueId(apiKey, identifier)
266+
if (!issueId) return { success: false, error: `Issue ${identifier} not found` }
267+
const data = await linearGraphQL<{
268+
commentCreate: { success: boolean; comment: { id: string; url: string } }
269+
}>(
270+
apiKey,
271+
`mutation CreateComment($input: CommentCreateInput!) {
272+
commentCreate(input: $input) { success comment { id url } }
273+
}`,
274+
{ input: { issueId, body } }
275+
)
276+
if (!data.commentCreate.success) {
277+
return { success: false, error: 'Linear commentCreate returned success=false' }
278+
}
279+
return { success: true, output: { url: data.commentCreate.comment.url } }
280+
}
281+
282+
case 'createIssue': {
283+
const title = typeof args.title === 'string' ? args.title : ''
284+
if (!title) return { success: false, error: 'title is required' }
285+
const description = typeof args.description === 'string' ? args.description : undefined
286+
const teamKey = typeof args.teamKey === 'string' ? args.teamKey : undefined
287+
if (!teamKey) {
288+
return {
289+
success: false,
290+
error: 'teamKey is required (set it on the connection or per-action)'
291+
}
292+
}
293+
const teamId = await resolveTeamId(apiKey, teamKey)
294+
if (!teamId) return { success: false, error: `Team ${teamKey} not found` }
295+
const input: Record<string, unknown> = { teamId, title }
296+
if (description) input.description = description
297+
const data = await linearGraphQL<{
298+
issueCreate: {
299+
success: boolean
300+
issue: { id: string; identifier: string; url: string }
301+
}
302+
}>(
303+
apiKey,
304+
`mutation CreateIssue($input: IssueCreateInput!) {
305+
issueCreate(input: $input) { success issue { id identifier url } }
306+
}`,
307+
{ input }
308+
)
309+
if (!data.issueCreate.success) {
310+
return { success: false, error: 'Linear issueCreate returned success=false' }
311+
}
312+
return {
313+
success: true,
314+
output: {
315+
identifier: data.issueCreate.issue.identifier,
316+
url: data.issueCreate.issue.url
317+
}
318+
}
319+
}
320+
321+
case 'closeIssue': {
322+
const identifier = typeof args.identifier === 'string' ? args.identifier : ''
323+
if (!identifier) return { success: false, error: 'identifier is required (e.g. ENG-123)' }
324+
const issue = await resolveIssueWithTeam(apiKey, identifier)
325+
if (!issue) return { success: false, error: `Issue ${identifier} not found` }
326+
// Pick a "completed"-type workflow state on the issue's team. Linear
327+
// doesn't have a single canonical "Done" — each team configures its
328+
// own, so we grab the first completed-type state.
329+
const stateId = await resolveCompletedStateId(apiKey, issue.teamId)
330+
if (!stateId) {
331+
return { success: false, error: `No completed-type state on team ${issue.teamKey}` }
332+
}
333+
const data = await linearGraphQL<{
334+
issueUpdate: { success: boolean; issue: { id: string; state: { name: string } } }
335+
}>(
336+
apiKey,
337+
`mutation CloseIssue($id: String!, $input: IssueUpdateInput!) {
338+
issueUpdate(id: $id, input: $input) { success issue { id state { name } } }
339+
}`,
340+
{ id: issue.id, input: { stateId } }
341+
)
342+
if (!data.issueUpdate.success) {
343+
return { success: false, error: 'Linear issueUpdate returned success=false' }
344+
}
345+
return { success: true, output: { state: data.issueUpdate.issue.state.name } }
346+
}
347+
348+
case 'syncTasks': {
349+
// Handled by the sync engine at a higher level; the action node
350+
// calls listItems() and does upsert logic.
351+
return { success: true }
352+
}
353+
354+
default:
355+
return { success: false, error: `Unknown action: ${actionType}` }
356+
}
196357
},
197358

198359
describe(): ConnectorManifest {
@@ -243,7 +404,75 @@ export const linearConnector: VornConnector = {
243404
defaultIntervalMs: 60_000
244405
}
245406
],
246-
actions: [],
407+
actions: [
408+
// teamKey / apiKey come from the connection's filters/auth and are
409+
// merged server-side before execute() runs, so they're not duplicated
410+
// in these configFields.
411+
{
412+
type: 'commentOnIssue',
413+
label: 'Comment on Issue',
414+
description: 'Post a comment on a Linear issue',
415+
configFields: [
416+
{
417+
key: 'identifier',
418+
label: 'Issue',
419+
type: 'text',
420+
required: true,
421+
placeholder: '{{connectorItem.externalId}}',
422+
supportsTemplates: true
423+
},
424+
{
425+
key: 'body',
426+
label: 'Comment',
427+
type: 'textarea',
428+
required: true,
429+
supportsTemplates: true
430+
}
431+
]
432+
},
433+
{
434+
type: 'createIssue',
435+
label: 'Create Issue',
436+
description: 'Create a new Linear issue',
437+
configFields: [
438+
{
439+
key: 'title',
440+
label: 'Title',
441+
type: 'text',
442+
required: true,
443+
supportsTemplates: true
444+
},
445+
{
446+
key: 'description',
447+
label: 'Description',
448+
type: 'textarea',
449+
supportsTemplates: true
450+
},
451+
{
452+
key: 'teamKey',
453+
label: 'Team Key',
454+
type: 'text',
455+
placeholder: 'ENG (defaults to connection team)',
456+
description: 'Override the connection team for this action.'
457+
}
458+
]
459+
},
460+
{
461+
type: 'closeIssue',
462+
label: 'Close Issue',
463+
description: "Move an issue to the team's default completed state",
464+
configFields: [
465+
{
466+
key: 'identifier',
467+
label: 'Issue',
468+
type: 'text',
469+
required: true,
470+
placeholder: '{{connectorItem.externalId}}',
471+
supportsTemplates: true
472+
}
473+
]
474+
}
475+
],
247476
defaultWorkflows: [
248477
{
249478
name: 'Linear: Issue Created',

0 commit comments

Comments
 (0)