From 87cacfa8e1140fac881f22541095dfea89d075d8 Mon Sep 17 00:00:00 2001 From: "genui-scotty[bot]" Date: Tue, 17 Mar 2026 18:06:21 -0700 Subject: [PATCH 1/4] feat: add milestone support to linear_issue and linear_project tools - Add resolveMilestoneId(projectId, name) resolver to linear-api.ts - Add milestone param (create + update) to linear_issue tool - create: requires project to be set; resolves milestone by name - update: uses existing issue project if no project param provided - Add projectMilestone to linear_issue view response - Add projectMilestones nodes to linear_project view response - Update SKILL.md docs with milestone param and resolver details - 11 new tests (389 total, all passing) --- skills/linear/SKILL.md | 5 +- src/linear-api.ts | 29 ++++++ src/tools/linear-issue-tool.ts | 41 +++++++- src/tools/linear-project-tool.ts | 8 ++ test/linear-api.test.ts | 60 ++++++++++++ test/tools/linear-issue-tool.test.ts | 127 ++++++++++++++++++++++++- test/tools/linear-project-tool.test.ts | 27 ++++++ 7 files changed, 289 insertions(+), 8 deletions(-) diff --git a/skills/linear/SKILL.md b/skills/linear/SKILL.md index c740ab5..658818e 100644 --- a/skills/linear/SKILL.md +++ b/skills/linear/SKILL.md @@ -54,8 +54,8 @@ Manage Linear issues: view details, search/filter, create, update, and delete. |---|---|---| | `view` | `issueId` | — | | `list` | — | `state`, `assignee`, `team`, `project`, `limit` | -| `create` | `title` | `description`, `assignee`, `state`, `priority`, `team`, `project`, `parent`, `labels`, `dueDate` | -| `update` | `issueId` | `title`, `description`, `appendDescription`, `assignee`, `state`, `priority`, `labels`, `project`, `dueDate` | +| `create` | `title` | `description`, `assignee`, `state`, `priority`, `team`, `project`, `parent`, `labels`, `dueDate`, `milestone` | +| `update` | `issueId` | `title`, `description`, `appendDescription`, `assignee`, `state`, `priority`, `labels`, `project`, `dueDate`, `milestone` | | `delete` | `issueId` | — | - `issueId` accepts human-readable identifiers like `ENG-123` @@ -67,6 +67,7 @@ Manage Linear issues: view details, search/filter, create, update, and delete. - `parent` accepts a parent issue identifier for creating sub-issues - `appendDescription` (boolean) — when true, appends `description` to the existing description instead of replacing it (update only) - `dueDate` accepts a date string in `YYYY-MM-DD` format (e.g. `2025-12-31`); pass an empty string to clear the due date +- `milestone` accepts a project milestone name (e.g. `"RL3 v0.2.0"`). Requires the issue to be associated with a project. Used with `create` and `update`. On `view`, the milestone is returned as `projectMilestone { id, name }`. - `description` supports markdown. **Use actual newlines for line breaks, not `\n` escape sequences** — literal `\n` will appear as-is in the ticket instead of creating line breaks ### `linear_comment` — manage comments diff --git a/src/linear-api.ts b/src/linear-api.ts index c60af5c..9533e9b 100644 --- a/src/linear-api.ts +++ b/src/linear-api.ts @@ -220,3 +220,32 @@ export async function resolveProjectId(name: string): Promise { } return data.projects.nodes[0].id } + +export async function resolveMilestoneId(projectId: string, name: string): Promise { + const data = await graphql<{ + project: { projectMilestones: { nodes: { id: string; name: string }[] } } + }>( + ` + query ($projectId: String!) { + project(id: $projectId) { + projectMilestones { + nodes { + id + name + } + } + } + } + `, + { projectId }, + ) + + const lowerName = name.toLowerCase() + const match = data.project.projectMilestones.nodes.find((m) => m.name.toLowerCase() === lowerName) + + if (!match) { + const available = data.project.projectMilestones.nodes.map((m) => m.name).join(', ') + throw new Error(`Milestone "${name}" not found in project. Available milestones: ${available || '(none)'}`) + } + return match.id +} diff --git a/src/tools/linear-issue-tool.ts b/src/tools/linear-issue-tool.ts index 29c8d0f..1982de6 100644 --- a/src/tools/linear-issue-tool.ts +++ b/src/tools/linear-issue-tool.ts @@ -8,6 +8,7 @@ import { resolveUserId, resolveLabelIds, resolveProjectId, + resolveMilestoneId, } from '../linear-api.js' const Params = Type.Object({ @@ -60,6 +61,12 @@ const Params = Type.Object({ description: 'Due date in YYYY-MM-DD format (e.g. 2025-12-31). Pass null or empty string to clear.', }), ), + milestone: Type.Optional( + Type.String({ + description: + 'Project milestone name to associate the issue with. Requires the issue to be part of a project. Used with create and update.', + }), + ), limit: Type.Optional( Type.Number({ description: 'Max results for list (default 50).', @@ -142,6 +149,10 @@ async function viewIssue(params: Params) { id name } + projectMilestone { + id + name + } parent { id identifier @@ -281,6 +292,12 @@ async function createIssue(params: Params) { input.labelIds = await resolveLabelIds(input.teamId as string, params.labels) } if (params.dueDate !== undefined) input.dueDate = params.dueDate || null + if (params.milestone) { + if (!input.projectId) { + return jsonResult({ error: 'milestone requires project to be set' }) + } + input.projectMilestoneId = await resolveMilestoneId(input.projectId as string, params.milestone) + } const data = await graphql<{ issueCreate: { @@ -307,14 +324,16 @@ async function createIssue(params: Params) { return jsonResult(data.issueCreate) } -async function resolveUpdateInput(params: Params, id: string): Promise> { +async function resolveUpdateInput(params: Params, id: string): Promise | { error: string }> { const input: Record = {} - // Fetch team ID (for state/label resolution) and current description (for append) + // Fetch team ID (for state/label resolution), current description (for append), + // and current project ID (for milestone resolution when project not explicitly provided) let teamId: string | undefined - if (params.state ?? params.labels?.length ?? params.appendDescription) { + let existingProjectId: string | undefined + if (params.state ?? params.labels?.length ?? params.appendDescription ?? params.milestone) { const issueData = await graphql<{ - issue: { team: { id: string }; description?: string } + issue: { team: { id: string }; description?: string; project?: { id: string } } }>( ` query ($id: String!) { @@ -323,12 +342,16 @@ async function resolveUpdateInput(params: Params, id: string): Promise { await expect(resolveProjectId('Ghost Project')).rejects.toThrow('Project "Ghost Project" not found') }) }) + +// --------------------------------------------------------------------------- +// resolveMilestoneId() +// --------------------------------------------------------------------------- + +describe('resolveMilestoneId()', () => { + const milestoneData = { + data: { + project: { + projectMilestones: { + nodes: [ + { id: 'ms-alpha', name: 'Alpha Release' }, + { id: 'ms-beta', name: 'Beta Release' }, + ], + }, + }, + }, + } + + it('returns milestone UUID for a matching name', async () => { + vi.stubGlobal('fetch', mockFetch(milestoneData)) + const id = await resolveMilestoneId('proj-1', 'Alpha Release') + expect(id).toBe('ms-alpha') + }) + + it('matches milestone names case-insensitively', async () => { + vi.stubGlobal('fetch', mockFetch(milestoneData)) + const id = await resolveMilestoneId('proj-1', 'beta release') + expect(id).toBe('ms-beta') + }) + + it('throws when milestone not found, listing available options', async () => { + vi.stubGlobal('fetch', mockFetch(milestoneData)) + await expect(resolveMilestoneId('proj-1', 'Gamma')).rejects.toThrow( + 'Milestone "Gamma" not found in project. Available milestones: Alpha Release, Beta Release', + ) + }) + + it('throws with (none) when project has no milestones', async () => { + vi.stubGlobal( + 'fetch', + mockFetch({ data: { project: { projectMilestones: { nodes: [] } } } }), + ) + await expect(resolveMilestoneId('proj-1', 'M1')).rejects.toThrow( + 'Milestone "M1" not found in project. Available milestones: (none)', + ) + }) + + it('passes projectId as a variable to the query', async () => { + const mockFn = mockFetch(milestoneData) + vi.stubGlobal('fetch', mockFn) + await resolveMilestoneId('proj-abc', 'Alpha Release') + + const body = JSON.parse(mockFn.mock.calls[0][1].body as string) as { + variables: Record + } + expect(body.variables.projectId).toBe('proj-abc') + }) +}) diff --git a/test/tools/linear-issue-tool.test.ts b/test/tools/linear-issue-tool.test.ts index f69a1e6..f0d3644 100644 --- a/test/tools/linear-issue-tool.test.ts +++ b/test/tools/linear-issue-tool.test.ts @@ -8,10 +8,19 @@ vi.mock('../../src/linear-api.js', () => ({ resolveUserId: vi.fn(), resolveLabelIds: vi.fn(), resolveProjectId: vi.fn(), + resolveMilestoneId: vi.fn(), })) -const { graphql, resolveIssueId, resolveTeamId, resolveStateId, resolveUserId, resolveLabelIds, resolveProjectId } = - await import('../../src/linear-api.js') +const { + graphql, + resolveIssueId, + resolveTeamId, + resolveStateId, + resolveUserId, + resolveLabelIds, + resolveProjectId, + resolveMilestoneId, +} = await import('../../src/linear-api.js') const { createIssueTool } = await import('../../src/tools/linear-issue-tool.js') const mockedGraphql = vi.mocked(graphql) @@ -21,6 +30,7 @@ const mockedResolveStateId = vi.mocked(resolveStateId) const mockedResolveUserId = vi.mocked(resolveUserId) const mockedResolveLabelIds = vi.mocked(resolveLabelIds) const mockedResolveProjectId = vi.mocked(resolveProjectId) +const mockedResolveMilestoneId = vi.mocked(resolveMilestoneId) function parse(result: { content: { type: string; text?: string }[] }) { const text = result.content.find((c) => c.type === 'text')?.text @@ -204,6 +214,51 @@ describe('linear_issue tool', () => { ) expect(data.error).toContain('No teams found') }) + + it('resolves milestone and passes projectMilestoneId when milestone and project provided', async () => { + mockedResolveTeamId.mockResolvedValue('team-uuid') + mockedResolveProjectId.mockResolvedValue('proj-uuid') + mockedResolveMilestoneId.mockResolvedValue('milestone-uuid') + mockedGraphql.mockResolvedValue({ + issueCreate: { + success: true, + issue: { id: 'new', identifier: 'ENG-101', url: 'u', title: 'Milestoned' }, + }, + }) + + await createIssueTool().execute('call-1', { + action: 'create', + title: 'Milestoned', + team: 'ENG', + project: 'Alpha', + milestone: 'RL3 v0.2.0', + }) + + expect(mockedResolveProjectId).toHaveBeenCalledWith('Alpha') + expect(mockedResolveMilestoneId).toHaveBeenCalledWith('proj-uuid', 'RL3 v0.2.0') + const input = getMutationInput() + expect(input.projectMilestoneId).toBe('milestone-uuid') + }) + + it('returns error when milestone provided without project', async () => { + mockedResolveTeamId.mockResolvedValue('team-uuid') + mockedGraphql.mockResolvedValue({ + issueCreate: { + success: true, + issue: { id: 'new', identifier: 'ENG-102', url: 'u', title: 'T' }, + }, + }) + + const data = parse( + await createIssueTool().execute('call-1', { + action: 'create', + title: 'No project', + team: 'ENG', + milestone: 'M1', + }), + ) + expect(data.error).toContain('milestone requires project') + }) }) describe('update', () => { @@ -316,6 +371,74 @@ describe('linear_issue tool', () => { ) expect(data.error).toContain('issueId is required') }) + + it('resolves milestone using existing project when no project param provided', async () => { + mockedResolveIssueId.mockResolvedValue('uuid-1') + mockedResolveMilestoneId.mockResolvedValue('ms-uuid') + mockedGraphql + .mockResolvedValueOnce({ + issue: { team: { id: 'team-1' }, description: null, project: { id: 'existing-proj' } }, + }) + .mockResolvedValueOnce({ + issueUpdate: { + success: true, + issue: { id: 'uuid-1', identifier: 'ENG-42', title: 'T' }, + }, + }) + + await createIssueTool().execute('call-1', { + action: 'update', + issueId: 'ENG-42', + milestone: 'Sprint 1', + }) + + expect(mockedResolveMilestoneId).toHaveBeenCalledWith('existing-proj', 'Sprint 1') + const vars = mockedGraphql.mock.calls[1][1] as { input: Record } + expect(vars.input.projectMilestoneId).toBe('ms-uuid') + }) + + it('resolves milestone using provided project param when both are given', async () => { + mockedResolveIssueId.mockResolvedValue('uuid-1') + mockedResolveProjectId.mockResolvedValue('new-proj-uuid') + mockedResolveMilestoneId.mockResolvedValue('ms-uuid-2') + mockedGraphql + .mockResolvedValueOnce({ + issue: { team: { id: 'team-1' }, description: null, project: { id: 'old-proj' } }, + }) + .mockResolvedValueOnce({ + issueUpdate: { + success: true, + issue: { id: 'uuid-1', identifier: 'ENG-42', title: 'T' }, + }, + }) + + await createIssueTool().execute('call-1', { + action: 'update', + issueId: 'ENG-42', + project: 'NewProject', + milestone: 'M2', + }) + + expect(mockedResolveMilestoneId).toHaveBeenCalledWith('new-proj-uuid', 'M2') + const vars = mockedGraphql.mock.calls[1][1] as { input: Record } + expect(vars.input.projectMilestoneId).toBe('ms-uuid-2') + }) + + it('returns error when milestone set but issue has no project and no project param', async () => { + mockedResolveIssueId.mockResolvedValue('uuid-1') + mockedGraphql.mockResolvedValueOnce({ + issue: { team: { id: 'team-1' }, description: null, project: null }, + }) + + const data = parse( + await createIssueTool().execute('call-1', { + action: 'update', + issueId: 'ENG-42', + milestone: 'M1', + }), + ) + expect(data.error).toContain('milestone requires') + }) }) describe('delete', () => { diff --git a/test/tools/linear-project-tool.test.ts b/test/tools/linear-project-tool.test.ts index 7677977..bc0cf39 100644 --- a/test/tools/linear-project-tool.test.ts +++ b/test/tools/linear-project-tool.test.ts @@ -82,6 +82,33 @@ describe('linear_project tool', () => { expect(data.name).toBe('Alpha') }) + it('includes projectMilestones in the view query', async () => { + mockedGraphql.mockResolvedValue({ + project: { + id: 'p1', + name: 'Alpha', + projectMilestones: { + nodes: [ + { id: 'ms-1', name: 'v0.1.0', targetDate: '2026-03-01', description: 'First milestone' }, + { id: 'ms-2', name: 'v0.2.0', targetDate: '2026-04-01', description: null }, + ], + }, + }, + }) + + const tool = createProjectTool() + const result = await tool.execute('call-1', { action: 'view', projectId: 'p1' }) + const data = parse(result) + expect(data.projectMilestones.nodes).toHaveLength(2) + expect(data.projectMilestones.nodes[0].name).toBe('v0.1.0') + expect(data.projectMilestones.nodes[1].name).toBe('v0.2.0') + + // Verify the query requests projectMilestones fields + const query = mockedGraphql.mock.calls[0][0] + expect(query).toContain('projectMilestones') + expect(query).toContain('targetDate') + }) + it('returns error without projectId', async () => { const tool = createProjectTool() const result = await tool.execute('call-1', { action: 'view' }) From b6de0f8896feff33a7fb882522b20c5d6cbf3c46 Mon Sep 17 00:00:00 2001 From: "genui-scotty[bot]" Date: Tue, 17 Mar 2026 18:15:28 -0700 Subject: [PATCH 2/4] style: run prettier on test/linear-api.test.ts --- test/linear-api.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/linear-api.test.ts b/test/linear-api.test.ts index ac8b0e9..b36f835 100644 --- a/test/linear-api.test.ts +++ b/test/linear-api.test.ts @@ -389,10 +389,7 @@ describe('resolveMilestoneId()', () => { }) it('throws with (none) when project has no milestones', async () => { - vi.stubGlobal( - 'fetch', - mockFetch({ data: { project: { projectMilestones: { nodes: [] } } } }), - ) + vi.stubGlobal('fetch', mockFetch({ data: { project: { projectMilestones: { nodes: [] } } } })) await expect(resolveMilestoneId('proj-1', 'M1')).rejects.toThrow( 'Milestone "M1" not found in project. Available milestones: (none)', ) From d59b2bac13fa4dcf4c4ca54793432edff314f8f7 Mon Sep 17 00:00:00 2001 From: "genui-scotty[bot]" Date: Tue, 17 Mar 2026 18:24:07 -0700 Subject: [PATCH 3/4] refactor: split resolveUpdateInput to fix complexity and lint errors - Extract fetchIssueContext() for issue team/description/project lookup - Extract buildScalarFields() (sync) for simple field assignment - Extract buildResolvedFields() for async resolver calls - Resolves: complexity > 20, require-atomic-updates, await-thenable --- src/tools/linear-issue-tool.ts | 109 ++++++++++++++++++++------------- 1 file changed, 66 insertions(+), 43 deletions(-) diff --git a/src/tools/linear-issue-tool.ts b/src/tools/linear-issue-tool.ts index 1982de6..6b7480b 100644 --- a/src/tools/linear-issue-tool.ts +++ b/src/tools/linear-issue-tool.ts @@ -324,58 +324,81 @@ async function createIssue(params: Params) { return jsonResult(data.issueCreate) } -async function resolveUpdateInput(params: Params, id: string): Promise | { error: string }> { - const input: Record = {} +async function fetchIssueContext( + id: string, +): Promise<{ teamId: string; description: string | undefined; existingProjectId: string | undefined }> { + const issueData = await graphql<{ + issue: { team: { id: string }; description?: string; project?: { id: string } } + }>( + ` + query ($id: String!) { + issue(id: $id) { + team { id } + description + project { id } + } + } + `, + { id }, + ) + return { + teamId: issueData.issue.team.id, + description: issueData.issue.description, + existingProjectId: issueData.issue.project?.id, + } +} + +function buildScalarFields(params: Params, appendedDescription?: string): Record { + const fields: Record = {} + if (appendedDescription !== undefined) { + fields.description = appendedDescription + } else if (params.description !== undefined && !params.appendDescription) { + fields.description = params.description + } + if (params.title) fields.title = params.title + if (params.priority !== undefined) fields.priority = params.priority + if (params.dueDate !== undefined) fields.dueDate = params.dueDate || null + return fields +} - // Fetch team ID (for state/label resolution), current description (for append), - // and current project ID (for milestone resolution when project not explicitly provided) +async function buildResolvedFields( + params: Params, + teamId: string | undefined, + existingProjectId: string | undefined, +): Promise | { error: string }> { + const fields: Record = {} + if (params.state) fields.stateId = await resolveStateId(teamId!, params.state) + if (params.assignee) fields.assigneeId = await resolveUserId(params.assignee) + if (params.project) fields.projectId = await resolveProjectId(params.project) + if (params.labels?.length) fields.labelIds = await resolveLabelIds(teamId!, params.labels) + if (params.milestone) { + const projectId = (fields.projectId as string | undefined) ?? existingProjectId + if (!projectId) return { error: 'milestone requires the issue to be associated with a project' } + fields.projectMilestoneId = await resolveMilestoneId(projectId, params.milestone) + } + return fields +} + +async function resolveUpdateInput(params: Params, id: string): Promise | { error: string }> { + const needsContext = params.state ?? params.labels?.length ?? params.appendDescription ?? params.milestone let teamId: string | undefined let existingProjectId: string | undefined - if (params.state ?? params.labels?.length ?? params.appendDescription ?? params.milestone) { - const issueData = await graphql<{ - issue: { team: { id: string }; description?: string; project?: { id: string } } - }>( - ` - query ($id: String!) { - issue(id: $id) { - team { - id - } - description - project { - id - } - } - } - `, - { id }, - ) - teamId = issueData.issue.team.id - existingProjectId = issueData.issue.project?.id + let appendedDescription: string | undefined + if (needsContext) { + const ctx = await fetchIssueContext(id) + teamId = ctx.teamId + existingProjectId = ctx.existingProjectId if (params.appendDescription && params.description !== undefined) { - const existing = issueData.issue.description ?? '' - input.description = existing ? `${existing}\n\n${params.description}` : params.description + const existing = ctx.description ?? '' + appendedDescription = existing ? `${existing}\n\n${params.description}` : params.description } } - if (params.title) input.title = params.title - if (params.description !== undefined && !params.appendDescription) input.description = params.description - if (params.priority !== undefined) input.priority = params.priority - if (params.state) input.stateId = await resolveStateId(teamId!, params.state) - if (params.assignee) input.assigneeId = await resolveUserId(params.assignee) - if (params.project) input.projectId = await resolveProjectId(params.project) - if (params.labels?.length) input.labelIds = await resolveLabelIds(teamId!, params.labels) - if (params.dueDate !== undefined) input.dueDate = params.dueDate || null - if (params.milestone) { - const projectId = (input.projectId as string | undefined) ?? existingProjectId - if (!projectId) { - return { error: 'milestone requires the issue to be associated with a project' } - } - input.projectMilestoneId = await resolveMilestoneId(projectId, params.milestone) - } + const resolvedFields = await buildResolvedFields(params, teamId, existingProjectId) + if ('error' in resolvedFields) return resolvedFields - return input + return { ...buildScalarFields(params, appendedDescription), ...resolvedFields } } async function updateIssue(params: Params) { From 4eddf6acd050a64fd1fac2dec366e2ed7097799f Mon Sep 17 00:00:00 2001 From: "genui-scotty[bot]" Date: Tue, 17 Mar 2026 19:20:18 -0700 Subject: [PATCH 4/4] fix: ignore local build artifacts in prettier; fix format on issue tool --- .prettierignore | 3 +++ src/tools/linear-issue-tool.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.prettierignore b/.prettierignore index 9df58db..7983326 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,9 @@ node_modules dist +dist.bak coverage +genui-oc-linear-build +queue **/*.md **/*.yml **/*.yaml diff --git a/src/tools/linear-issue-tool.ts b/src/tools/linear-issue-tool.ts index 6b7480b..f332839 100644 --- a/src/tools/linear-issue-tool.ts +++ b/src/tools/linear-issue-tool.ts @@ -333,9 +333,13 @@ async function fetchIssueContext( ` query ($id: String!) { issue(id: $id) { - team { id } + team { + id + } description - project { id } + project { + id + } } } `,