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/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..f332839 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,45 +324,85 @@ async function createIssue(params: Params) { return jsonResult(data.issueCreate) } -async function resolveUpdateInput(params: Params, id: string): Promise> { - const input: Record = {} - - // Fetch team ID (for state/label resolution) and current description (for append) - let teamId: string | undefined - if (params.state ?? params.labels?.length ?? params.appendDescription) { - const issueData = await graphql<{ - issue: { team: { id: string }; description?: string } - }>( - ` - query ($id: String!) { - issue(id: $id) { - team { - id - } - description +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 }, - ) - teamId = issueData.issue.team.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 +} + +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 + 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 + 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) { @@ -355,6 +412,9 @@ async function updateIssue(params: Params) { const id = await resolveIssueId(params.issueId) const input = await resolveUpdateInput(params, id) + if ('error' in input) { + return jsonResult(input) + } const data = await graphql<{ issueUpdate: { diff --git a/src/tools/linear-project-tool.ts b/src/tools/linear-project-tool.ts index aa34a8e..a2f6815 100644 --- a/src/tools/linear-project-tool.ts +++ b/src/tools/linear-project-tool.ts @@ -145,6 +145,14 @@ async function viewProject(params: Params) { name } } + projectMilestones { + nodes { + id + name + targetDate + description + } + } } } `, diff --git a/test/linear-api.test.ts b/test/linear-api.test.ts index c019823..b36f835 100644 --- a/test/linear-api.test.ts +++ b/test/linear-api.test.ts @@ -18,6 +18,7 @@ import { resolveUserId, resolveLabelIds, resolveProjectId, + resolveMilestoneId, } from '../src/linear-api.js' // --------------------------------------------------------------------------- @@ -349,3 +350,59 @@ describe('resolveProjectId()', () => { 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' })