Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
node_modules
dist
dist.bak
coverage
genui-oc-linear-build
queue
**/*.md
**/*.yml
**/*.yaml
Expand Down
5 changes: 3 additions & 2 deletions skills/linear/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/linear-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,32 @@ export async function resolveProjectId(name: string): Promise<string> {
}
return data.projects.nodes[0].id
}

export async function resolveMilestoneId(projectId: string, name: string): Promise<string> {
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
}
122 changes: 91 additions & 31 deletions src/tools/linear-issue-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
resolveUserId,
resolveLabelIds,
resolveProjectId,
resolveMilestoneId,
} from '../linear-api.js'

const Params = Type.Object({
Expand Down Expand Up @@ -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).',
Expand Down Expand Up @@ -142,6 +149,10 @@ async function viewIssue(params: Params) {
id
name
}
projectMilestone {
id
name
}
parent {
id
identifier
Expand Down Expand Up @@ -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: {
Expand All @@ -307,45 +324,85 @@ async function createIssue(params: Params) {
return jsonResult(data.issueCreate)
}

async function resolveUpdateInput(params: Params, id: string): Promise<Record<string, unknown>> {
const input: Record<string, unknown> = {}

// 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<string, unknown> {
const fields: Record<string, unknown> = {}
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<Record<string, unknown> | { error: string }> {
const fields: Record<string, unknown> = {}
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<Record<string, unknown> | { 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) {
Expand All @@ -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: {
Expand Down
8 changes: 8 additions & 0 deletions src/tools/linear-project-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ async function viewProject(params: Params) {
name
}
}
projectMilestones {
nodes {
id
name
targetDate
description
}
}
}
}
`,
Expand Down
57 changes: 57 additions & 0 deletions test/linear-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
resolveUserId,
resolveLabelIds,
resolveProjectId,
resolveMilestoneId,
} from '../src/linear-api.js'

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<string, unknown>
}
expect(body.variables.projectId).toBe('proj-abc')
})
})
Loading
Loading