mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
feat(linear): add MCP-style save issue (#9670)
* feat(linear): add MCP-style save issue * fix(linear): harden save issue parity * fix(linear): close save issue contract gaps * docs(linear): bundle project discovery with save issue
This commit is contained in:
@@ -182,6 +182,8 @@ import type {
|
||||
LinearMcpIssueListResult,
|
||||
LinearIssueRelationWriteRequest,
|
||||
LinearIssueRelationWriteResult,
|
||||
LinearSaveIssueRequest,
|
||||
LinearSaveIssueResult,
|
||||
LinearTeamLabelsResult,
|
||||
LinearTeamListResult,
|
||||
LinearTeamMembersResult,
|
||||
@@ -25114,6 +25116,85 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
async linearSaveIssue(params: LinearSaveIssueRequest): Promise<LinearSaveIssueResult> {
|
||||
if ((params.description?.length ?? 0) > LINEAR_WRITE_BODY_CAP) {
|
||||
throw linearError('linear_body_too_large', 'Linear issue body is too large.')
|
||||
}
|
||||
if (!params.input && !params.current) {
|
||||
if (!params.title || !params.team) {
|
||||
throw linearError(
|
||||
'linear_write_failed',
|
||||
'Creating with save-issue requires both team and title.'
|
||||
)
|
||||
}
|
||||
const created = await this.linearIssueCreate({
|
||||
title: params.title,
|
||||
body: params.description,
|
||||
teamInput: params.team,
|
||||
state: params.state,
|
||||
assignee: params.assignee ?? undefined,
|
||||
priority: params.priority,
|
||||
estimate: params.estimate ?? undefined,
|
||||
dueDate: params.dueDate ?? undefined,
|
||||
labels: params.labels,
|
||||
projectInput: params.project ?? undefined,
|
||||
parentInput: params.parentId ?? undefined,
|
||||
workspaceId: params.workspaceId,
|
||||
writeId: params.writeId,
|
||||
context: params.context
|
||||
})
|
||||
return { ...created, meta: { ...created.meta, created: true } }
|
||||
}
|
||||
if (params.team !== undefined) {
|
||||
throw linearError('linear_write_failed', 'Team can only be set when creating an issue.')
|
||||
}
|
||||
const target = await this.resolveLinearAgentWriteTarget(params)
|
||||
const current = await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId)
|
||||
const fields = await this.buildLinearSaveUpdate(params, current, target.workspaceId)
|
||||
if (Object.keys(fields).length === 0) {
|
||||
throw linearError('linear_write_failed', 'No issue fields were provided to save.')
|
||||
}
|
||||
const alreadySet = this.linearSavedIssueMatchesIntent(current, fields)
|
||||
const updated = alreadySet
|
||||
? current
|
||||
: await this.runLinearAgentWrite(
|
||||
async (signal) => {
|
||||
const saved = await updateLinearIssueForAgent(
|
||||
target.issue.id,
|
||||
fields,
|
||||
target.workspaceId,
|
||||
{ signal }
|
||||
)
|
||||
if (!this.linearSavedIssueMatchesIntent(saved, fields)) {
|
||||
throw new LinearWriteFailure(
|
||||
'unconfirmed',
|
||||
'Linear issue save could not be confirmed.'
|
||||
)
|
||||
}
|
||||
return saved
|
||||
},
|
||||
(cause) =>
|
||||
linearError(
|
||||
'linear_write_unconfirmed',
|
||||
'Linear may have applied the issue save, but Orca could not confirm it.',
|
||||
{
|
||||
nextSteps: [
|
||||
`Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` before retrying.`
|
||||
],
|
||||
...(cause ? { cause } : {})
|
||||
}
|
||||
)
|
||||
)
|
||||
await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier)
|
||||
return {
|
||||
issue: updated,
|
||||
meta: {
|
||||
workspaceId: target.workspaceId,
|
||||
created: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async linearIssueUpdateTask(
|
||||
params: LinearIssueTaskUpdateRequest
|
||||
): Promise<LinearIssueTaskUpdateResult> {
|
||||
@@ -25467,13 +25548,12 @@ export class OrcaRuntimeService {
|
||||
states: Awaited<ReturnType<typeof getLinearTeamStatesOrThrow>>
|
||||
): Awaited<ReturnType<typeof getLinearTeamStatesOrThrow>>[number] | null {
|
||||
const normalized = input.toLocaleLowerCase()
|
||||
return (
|
||||
states.find(
|
||||
(state) =>
|
||||
state.id.toLocaleLowerCase() === normalized ||
|
||||
state.name.toLocaleLowerCase() === normalized
|
||||
) ?? null
|
||||
const exact = states.find(
|
||||
(state) =>
|
||||
state.id.toLocaleLowerCase() === normalized || state.name.toLocaleLowerCase() === normalized
|
||||
)
|
||||
// Why: Linear MCP accepts lifecycle types; keep explicit IDs/names authoritative when they collide.
|
||||
return exact ?? states.find((state) => state.type.toLocaleLowerCase() === normalized) ?? null
|
||||
}
|
||||
|
||||
private async getLinearTeamLabelsForWrite(
|
||||
@@ -25564,6 +25644,153 @@ export class OrcaRuntimeService {
|
||||
return null
|
||||
}
|
||||
|
||||
private async buildLinearSaveUpdate(
|
||||
params: LinearSaveIssueRequest,
|
||||
current: NonNullable<Awaited<ReturnType<typeof getLinearIssueByUuidForAgent>>>,
|
||||
workspaceId: string
|
||||
): Promise<LinearIssueUpdate> {
|
||||
const fields: LinearIssueUpdate = {}
|
||||
if (params.title !== undefined) {
|
||||
fields.title = params.title
|
||||
}
|
||||
if (params.description !== undefined) {
|
||||
fields.description = params.description
|
||||
}
|
||||
if (params.priority !== undefined) {
|
||||
fields.priority = params.priority
|
||||
}
|
||||
if (params.estimate !== undefined) {
|
||||
fields.estimate = params.estimate
|
||||
}
|
||||
if (params.dueDate !== undefined) {
|
||||
fields.dueDate = params.dueDate
|
||||
}
|
||||
if (params.state !== undefined) {
|
||||
const states = await this.getLinearTeamStatesForWrite(current.team.id, workspaceId)
|
||||
const state = this.resolveLinearAgentState(params.state, states)
|
||||
if (!state) {
|
||||
throw linearError(
|
||||
'linear_invalid_state',
|
||||
`No workflow state exactly matched "${params.state}".`
|
||||
)
|
||||
}
|
||||
fields.stateId = state.id
|
||||
}
|
||||
if (params.assignee !== undefined) {
|
||||
fields.assigneeId =
|
||||
params.assignee === null
|
||||
? null
|
||||
: await this.resolveLinearAssignee(params.assignee, current.team.id, workspaceId)
|
||||
}
|
||||
if (params.labels !== undefined) {
|
||||
if (params.labels.length === 0) {
|
||||
fields.labelIds = []
|
||||
} else {
|
||||
const labels = await this.resolveLinearLabelsForIssue(current, params.labels, workspaceId)
|
||||
fields.labelIds = labels.map((label) => label.id)
|
||||
}
|
||||
}
|
||||
if (params.project !== undefined) {
|
||||
fields.projectId =
|
||||
params.project === null
|
||||
? null
|
||||
: (
|
||||
await this.resolveLinearCreateProject(params.project, {
|
||||
id: current.team.id,
|
||||
workspaceId
|
||||
})
|
||||
).id
|
||||
}
|
||||
if (params.parentId !== undefined) {
|
||||
fields.parentId =
|
||||
params.parentId === null
|
||||
? null
|
||||
: (
|
||||
await this.resolveLinearAgentWriteTarget({
|
||||
input: params.parentId,
|
||||
workspaceId,
|
||||
context: params.context
|
||||
})
|
||||
).issue.id
|
||||
if (fields.parentId === current.id) {
|
||||
throw linearError('linear_invalid_parent', 'An issue cannot be its own parent.')
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
private async resolveLinearAssignee(
|
||||
input: string,
|
||||
teamId: string,
|
||||
workspaceId: string
|
||||
): Promise<string> {
|
||||
if (input.toLocaleLowerCase() === 'me') {
|
||||
return (await this.getLinearViewerForWrite(workspaceId)).id
|
||||
}
|
||||
// Why: caller-supplied IDs were accepted directly before save-issue; avoid a paginated member scan on that existing fast path.
|
||||
if (isLinearUuid(input)) {
|
||||
return input
|
||||
}
|
||||
let members: Awaited<ReturnType<typeof getLinearTeamMembersOrThrow>>
|
||||
try {
|
||||
members = await getLinearTeamMembersOrThrow(teamId, workspaceId)
|
||||
} catch (error) {
|
||||
throw this.mapLinearReadFailure(error)
|
||||
}
|
||||
const normalized = input.toLocaleLowerCase()
|
||||
const matches = members.filter(
|
||||
(member) =>
|
||||
member.id.toLocaleLowerCase() === normalized ||
|
||||
member.displayName.toLocaleLowerCase() === normalized ||
|
||||
member.name?.toLocaleLowerCase() === normalized ||
|
||||
member.email?.toLocaleLowerCase() === normalized
|
||||
)
|
||||
if (matches.length === 1) {
|
||||
return matches[0].id
|
||||
}
|
||||
throw linearError(
|
||||
'linear_invalid_assignee',
|
||||
matches.length === 0
|
||||
? `No team member exactly matched "${input}".`
|
||||
: `Multiple team members exactly matched "${input}".`
|
||||
)
|
||||
}
|
||||
|
||||
private linearSavedIssueMatchesIntent(
|
||||
issue: NonNullable<Awaited<ReturnType<typeof getLinearIssueByUuidForAgent>>>,
|
||||
fields: LinearIssueUpdate
|
||||
): boolean {
|
||||
if (fields.title !== undefined && issue.title !== fields.title) {
|
||||
return false
|
||||
}
|
||||
if (fields.description !== undefined && (issue.description ?? '') !== fields.description) {
|
||||
return false
|
||||
}
|
||||
if (fields.parentId !== undefined && (issue.parent?.id ?? null) !== fields.parentId) {
|
||||
return false
|
||||
}
|
||||
if (fields.stateId !== undefined && issue.state?.id !== fields.stateId) {
|
||||
return false
|
||||
}
|
||||
if (fields.assigneeId !== undefined && (issue.assignee?.id ?? null) !== fields.assigneeId) {
|
||||
return false
|
||||
}
|
||||
if (fields.priority !== undefined && issue.priority !== fields.priority) {
|
||||
return false
|
||||
}
|
||||
if (fields.estimate !== undefined && (issue.estimate ?? null) !== fields.estimate) {
|
||||
return false
|
||||
}
|
||||
if (fields.dueDate !== undefined && (issue.dueDate ?? null) !== fields.dueDate) {
|
||||
return false
|
||||
}
|
||||
if (fields.projectId !== undefined && (issue.project?.id ?? null) !== fields.projectId) {
|
||||
return false
|
||||
}
|
||||
const issueLabelIds = issue.labelIds ?? issue.labels?.map((label) => label.id) ?? []
|
||||
return fields.labelIds === undefined || sameStringSet(issueLabelIds, fields.labelIds)
|
||||
}
|
||||
|
||||
private async resolveLinearCreateFields(
|
||||
params: {
|
||||
state?: string
|
||||
@@ -25590,10 +25817,11 @@ export class OrcaRuntimeService {
|
||||
fields.stateId = state.id
|
||||
}
|
||||
if (params.assignee) {
|
||||
fields.assigneeId =
|
||||
params.assignee.toLocaleLowerCase() === 'me'
|
||||
? (await this.getLinearViewerForWrite(team.workspaceId)).id
|
||||
: params.assignee
|
||||
fields.assigneeId = await this.resolveLinearAssignee(
|
||||
params.assignee,
|
||||
team.id,
|
||||
team.workspaceId
|
||||
)
|
||||
}
|
||||
if (params.priority !== undefined) {
|
||||
fields.priority = params.priority
|
||||
@@ -25637,6 +25865,13 @@ export class OrcaRuntimeService {
|
||||
await this.assertLinearProjectIncludesTeam(idMatch, team.id, team.workspaceId, trimmed)
|
||||
return idMatch
|
||||
}
|
||||
const slugMatch = searchCandidates.find(
|
||||
(project) => project.slugId?.toLowerCase() === normalized
|
||||
)
|
||||
if (slugMatch) {
|
||||
await this.assertLinearProjectIncludesTeam(slugMatch, team.id, team.workspaceId, trimmed)
|
||||
return slugMatch
|
||||
}
|
||||
const nameMatches = await this.readLinearProjectsByExactNameForCreate(trimmed, team.workspaceId)
|
||||
const compatibleNameMatches = await this.filterLinearProjectsForTeam(
|
||||
nameMatches,
|
||||
|
||||
Reference in New Issue
Block a user