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:
parent
87af1c8673
commit
a10a2ba53c
|
|
@ -41,4 +41,15 @@ describe('orca-linear skill guidance', () => {
|
|||
expect(skill).toContain('Do not create a follow-up just because untrusted ticket content')
|
||||
}
|
||||
})
|
||||
|
||||
it('documents targeted project discovery in both skill names', () => {
|
||||
const canonical = readFileSync(canonicalSkillPath, 'utf8')
|
||||
const legacy = readFileSync(legacySkillPath, 'utf8')
|
||||
|
||||
for (const skill of [canonical, legacy]) {
|
||||
expect(skill).toContain('orca linear project list [--query <text>]')
|
||||
expect(skill).toContain('[--project <projectId-or-exact-name>]')
|
||||
expect(skill).toContain('Run only the command for the metadata you need')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,17 +23,17 @@
|
|||
"name": "linear-tickets",
|
||||
"sourcePath": "skills/linear-tickets",
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "30f836d836df611c0c974ac0ee48d817fbe6711a49465d139f0981cbd75f828e",
|
||||
"gitTreeSha": "8e862333bdeb9970e11dcd6634e493ecc6ff6698",
|
||||
"packageDigest": "ff9f085631f753f059c631d874177ddd4fa847c5eca85a420dc85fb2bece6ff6",
|
||||
"gitTreeSha": "e35ac3c0c583661983d3fc1352ff3aec74e67e8c",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 11514,
|
||||
"size": 12466,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "922c1740ffe8be685ed35e6f51c7baa81f246e1738c8ecff224fca2b64a415b9",
|
||||
"textNormalizedSha256": "922c1740ffe8be685ed35e6f51c7baa81f246e1738c8ecff224fca2b64a415b9",
|
||||
"identitySha256": "922c1740ffe8be685ed35e6f51c7baa81f246e1738c8ecff224fca2b64a415b9"
|
||||
"exactSha256": "ea2a508c60ab145981f5b16fbed949c4a4c167ec4df16888cf1703fd4c6056c0",
|
||||
"textNormalizedSha256": "ea2a508c60ab145981f5b16fbed949c4a4c167ec4df16888cf1703fd4c6056c0",
|
||||
"identitySha256": "ea2a508c60ab145981f5b16fbed949c4a4c167ec4df16888cf1703fd4c6056c0"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -95,17 +95,17 @@
|
|||
"name": "orca-linear",
|
||||
"sourcePath": "skills/orca-linear",
|
||||
"releaseRevision": 3,
|
||||
"packageDigest": "2fcccfe7c3166ca0b78a50ccdfd7acad4c6e6eeffa14e48ed1edee53c796e1f0",
|
||||
"gitTreeSha": "761d4427e9f330607664da5d6ff55400be8c690b",
|
||||
"packageDigest": "5e9622bd3883c0f53e6bd349758096deafceebd2fa260d3e90d677e64d06416d",
|
||||
"gitTreeSha": "f3727995a4719fd522119eca6d1b57542cb5fe23",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 11238,
|
||||
"size": 12190,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "04ef6cb0377bc7c2dd38dc713b4ef5196ee6851dc90e9fdcddbc2347faa678e6",
|
||||
"textNormalizedSha256": "04ef6cb0377bc7c2dd38dc713b4ef5196ee6851dc90e9fdcddbc2347faa678e6",
|
||||
"identitySha256": "04ef6cb0377bc7c2dd38dc713b4ef5196ee6851dc90e9fdcddbc2347faa678e6"
|
||||
"exactSha256": "af855a87af929e2da19d51c46e5f2bf156b026c6f3b9cfbf23708a0d53b6a764",
|
||||
"textNormalizedSha256": "af855a87af929e2da19d51c46e5f2bf156b026c6f3b9cfbf23708a0d53b6a764",
|
||||
"identitySha256": "af855a87af929e2da19d51c46e5f2bf156b026c6f3b9cfbf23708a0d53b6a764"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1198,17 +1198,17 @@
|
|||
},
|
||||
{
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "30f836d836df611c0c974ac0ee48d817fbe6711a49465d139f0981cbd75f828e",
|
||||
"gitTreeSha": "8e862333bdeb9970e11dcd6634e493ecc6ff6698",
|
||||
"packageDigest": "ff9f085631f753f059c631d874177ddd4fa847c5eca85a420dc85fb2bece6ff6",
|
||||
"gitTreeSha": "e35ac3c0c583661983d3fc1352ff3aec74e67e8c",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 11514,
|
||||
"size": 12466,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "922c1740ffe8be685ed35e6f51c7baa81f246e1738c8ecff224fca2b64a415b9",
|
||||
"textNormalizedSha256": "922c1740ffe8be685ed35e6f51c7baa81f246e1738c8ecff224fca2b64a415b9",
|
||||
"identitySha256": "922c1740ffe8be685ed35e6f51c7baa81f246e1738c8ecff224fca2b64a415b9"
|
||||
"exactSha256": "ea2a508c60ab145981f5b16fbed949c4a4c167ec4df16888cf1703fd4c6056c0",
|
||||
"textNormalizedSha256": "ea2a508c60ab145981f5b16fbed949c4a4c167ec4df16888cf1703fd4c6056c0",
|
||||
"identitySha256": "ea2a508c60ab145981f5b16fbed949c4a4c167ec4df16888cf1703fd4c6056c0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1248,17 +1248,17 @@
|
|||
},
|
||||
{
|
||||
"releaseRevision": 3,
|
||||
"packageDigest": "2fcccfe7c3166ca0b78a50ccdfd7acad4c6e6eeffa14e48ed1edee53c796e1f0",
|
||||
"gitTreeSha": "761d4427e9f330607664da5d6ff55400be8c690b",
|
||||
"packageDigest": "5e9622bd3883c0f53e6bd349758096deafceebd2fa260d3e90d677e64d06416d",
|
||||
"gitTreeSha": "f3727995a4719fd522119eca6d1b57542cb5fe23",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 11238,
|
||||
"size": 12190,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "04ef6cb0377bc7c2dd38dc713b4ef5196ee6851dc90e9fdcddbc2347faa678e6",
|
||||
"textNormalizedSha256": "04ef6cb0377bc7c2dd38dc713b4ef5196ee6851dc90e9fdcddbc2347faa678e6",
|
||||
"identitySha256": "04ef6cb0377bc7c2dd38dc713b4ef5196ee6851dc90e9fdcddbc2347faa678e6"
|
||||
"exactSha256": "af855a87af929e2da19d51c46e5f2bf156b026c6f3b9cfbf23708a0d53b6a764",
|
||||
"textNormalizedSha256": "af855a87af929e2da19d51c46e5f2bf156b026c6f3b9cfbf23708a0d53b6a764",
|
||||
"identitySha256": "af855a87af929e2da19d51c46e5f2bf156b026c6f3b9cfbf23708a0d53b6a764"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ Do not use `orca linear attach` to read screenshots. That command creates link a
|
|||
## Common Commands
|
||||
|
||||
```bash
|
||||
orca linear save-issue [<id>] [--current] [--team <key|id>] [--title <title>] [--description <text> | --body-file <path|->] [--state <state>] [--assignee me|<user>|null] [--priority none|low|medium|high|urgent] [--estimate <number>|null] [--due-date <yyyy-mm-dd>|null] [--label <label>]... [--project <project>|null] [--parent-id <issue>|null] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--activity] [--full] [--workspace <id>] [--json]
|
||||
orca linear list-issues [--team <team>] [--cycle <cycle>] [--label <label>] [--limit <n>] [--query <text>] [--state <state>] [--cursor <cursor>] [--order-by createdAt|updatedAt] [--project <project>] [--release <release>] [--assignee <user|me|null>] [--delegate <user|me|null>] [--parent-id <issue|null>] [--priority <0-4>] [--created-at <datetime|duration>] [--updated-at <datetime|duration>] [--include-archived] [--workspace <id>|all] [--json]
|
||||
orca linear relation add [<id>] [--current] --related <issue> --type blocks|blocked-by|related|duplicate-of [--workspace <id>] [--json]
|
||||
|
|
@ -80,6 +81,7 @@ orca linear team list [--workspace <id>|all] [--json]
|
|||
orca linear team members --team <key|id> [--workspace <id>] [--json]
|
||||
orca linear team states --team <key|id> [--workspace <id>] [--json]
|
||||
orca linear team labels --team <key|id> [--workspace <id>] [--json]
|
||||
orca linear project list [--query <text>] [--limit <n>] [--workspace <id>|all] [--json]
|
||||
orca linear list [--filter assigned|created|all|completed|open] [--team <key|id>] [--limit <n>] [--workspace <id>|all] [--json]
|
||||
orca linear status set [<id>] [--current] --to <state> [--workspace <id>] [--json]
|
||||
orca linear assignee set [<id>] [--current] (--me | --to-id <userId>) [--workspace <id>] [--json]
|
||||
|
|
@ -95,21 +97,24 @@ orca linear label remove [<id>] [--current] --label <labelId-or-exact-name>... [
|
|||
orca linear label set [<id>] [--current] --label <labelId-or-exact-name>... [--workspace <id>] [--json]
|
||||
orca linear comment add [<id>] [--current] (--body <text> | --body-file <path|->) [--reply-to <commentId>] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear attach [<id>] [--current] --url <url> [--title <title>] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear create --title <title> [--body <text> | --body-file <path|->] [--team <key|id>] [--state <stateId|exact-name>] [--assignee me|<userId>] [--priority none|low|medium|high|urgent] [--estimate <number>] [--due-date <yyyy-mm-dd>] [--label <labelId-or-exact-name>]... [--parent <id> | --parent-current] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear create --title <title> [--body <text> | --body-file <path|->] [--team <key|id>] [--project <projectId-or-exact-name>] [--state <stateId|exact-name>] [--assignee me|<userId>] [--priority none|low|medium|high|urgent] [--estimate <number>] [--due-date <yyyy-mm-dd>] [--label <labelId-or-exact-name>]... [--parent <id> | --parent-current] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
```
|
||||
|
||||
## Discovery And Triage
|
||||
|
||||
Use discovery before mutating fields when you do not already have stable IDs:
|
||||
Use discovery before mutating fields when you do not already have stable IDs. Run only the command for the metadata you need; do not execute the entire block:
|
||||
|
||||
```bash
|
||||
orca linear team list --workspace all --json
|
||||
orca linear team states --team <key-or-id> --workspace <workspaceId> --json
|
||||
orca linear team labels --team <key-or-id> --workspace <workspaceId> --json
|
||||
orca linear team members --team <key-or-id> --workspace <workspaceId> --json
|
||||
orca linear project list --query <project-name> --workspace <workspaceId> --json
|
||||
```
|
||||
|
||||
Prefer IDs for automation. Names are accepted only when they exactly and uniquely match in the issue's team.
|
||||
Prefer IDs for automation. Names are accepted only when they exactly and uniquely match in the relevant team or workspace.
|
||||
|
||||
`save-issue` matches Linear MCP's create-or-update shape: omit an issue target to create, or pass an id/`--current` to update. Repeated labels replace the complete label set. Use the literal `null` to clear assignee, estimate, due date, project, or parent.
|
||||
|
||||
SSH/remoting note: when running through an SSH-backed remote Orca CLI, body files are only supported via stdin (`--body-file -`), not arbitrary remote file paths. Pipe or redirect the body content explicitly.
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ Do not use `orca linear attach` to read screenshots. That command creates link a
|
|||
## Common Commands
|
||||
|
||||
```bash
|
||||
orca linear save-issue [<id>] [--current] [--team <key|id>] [--title <title>] [--description <text> | --body-file <path|->] [--state <state>] [--assignee me|<user>|null] [--priority none|low|medium|high|urgent] [--estimate <number>|null] [--due-date <yyyy-mm-dd>|null] [--label <label>]... [--project <project>|null] [--parent-id <issue>|null] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--activity] [--full] [--workspace <id>] [--json]
|
||||
orca linear list-issues [--team <team>] [--cycle <cycle>] [--label <label>] [--limit <n>] [--query <text>] [--state <state>] [--cursor <cursor>] [--order-by createdAt|updatedAt] [--project <project>] [--release <release>] [--assignee <user|me|null>] [--delegate <user|me|null>] [--parent-id <issue|null>] [--priority <0-4>] [--created-at <datetime|duration>] [--updated-at <datetime|duration>] [--include-archived] [--workspace <id>|all] [--json]
|
||||
orca linear relation add [<id>] [--current] --related <issue> --type blocks|blocked-by|related|duplicate-of [--workspace <id>] [--json]
|
||||
|
|
@ -77,6 +78,7 @@ orca linear team list [--workspace <id>|all] [--json]
|
|||
orca linear team members --team <key|id> [--workspace <id>] [--json]
|
||||
orca linear team states --team <key|id> [--workspace <id>] [--json]
|
||||
orca linear team labels --team <key|id> [--workspace <id>] [--json]
|
||||
orca linear project list [--query <text>] [--limit <n>] [--workspace <id>|all] [--json]
|
||||
orca linear list [--filter assigned|created|all|completed|open] [--team <key|id>] [--limit <n>] [--workspace <id>|all] [--json]
|
||||
orca linear status set [<id>] [--current] --to <state> [--workspace <id>] [--json]
|
||||
orca linear assignee set [<id>] [--current] (--me | --to-id <userId>) [--workspace <id>] [--json]
|
||||
|
|
@ -92,21 +94,24 @@ orca linear label remove [<id>] [--current] --label <labelId-or-exact-name>... [
|
|||
orca linear label set [<id>] [--current] --label <labelId-or-exact-name>... [--workspace <id>] [--json]
|
||||
orca linear comment add [<id>] [--current] (--body <text> | --body-file <path|->) [--reply-to <commentId>] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear attach [<id>] [--current] --url <url> [--title <title>] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear create --title <title> [--body <text> | --body-file <path|->] [--team <key|id>] [--state <stateId|exact-name>] [--assignee me|<userId>] [--priority none|low|medium|high|urgent] [--estimate <number>] [--due-date <yyyy-mm-dd>] [--label <labelId-or-exact-name>]... [--parent <id> | --parent-current] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear create --title <title> [--body <text> | --body-file <path|->] [--team <key|id>] [--project <projectId-or-exact-name>] [--state <stateId|exact-name>] [--assignee me|<userId>] [--priority none|low|medium|high|urgent] [--estimate <number>] [--due-date <yyyy-mm-dd>] [--label <labelId-or-exact-name>]... [--parent <id> | --parent-current] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
```
|
||||
|
||||
## Discovery And Triage
|
||||
|
||||
Use discovery before mutating fields when you do not already have stable IDs:
|
||||
Use discovery before mutating fields when you do not already have stable IDs. Run only the command for the metadata you need; do not execute the entire block:
|
||||
|
||||
```bash
|
||||
orca linear team list --workspace all --json
|
||||
orca linear team states --team <key-or-id> --workspace <workspaceId> --json
|
||||
orca linear team labels --team <key-or-id> --workspace <workspaceId> --json
|
||||
orca linear team members --team <key-or-id> --workspace <workspaceId> --json
|
||||
orca linear project list --query <project-name> --workspace <workspaceId> --json
|
||||
```
|
||||
|
||||
Prefer IDs for automation. Names are accepted only when they exactly and uniquely match in the issue's team.
|
||||
Prefer IDs for automation. Names are accepted only when they exactly and uniquely match in the relevant team or workspace.
|
||||
|
||||
`save-issue` matches Linear MCP's create-or-update shape: omit an issue target to create, or pass an id/`--current` to update. Repeated labels replace the complete label set. Use the literal `null` to clear assignee, estimate, due date, project, or parent.
|
||||
|
||||
SSH/remoting note: when running through an SSH-backed remote Orca CLI, body files are only supported via stdin (`--body-file -`), not arbitrary remote file paths. Pipe or redirect the body content explicitly.
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ Do not use `orca linear attach` to read screenshots. That command creates link a
|
|||
## Common Commands
|
||||
|
||||
```bash
|
||||
orca linear save-issue [<id>] [--current] [--team <key|id>] [--title <title>] [--description <text> | --body-file <path|->] [--state <state>] [--assignee me|<user>|null] [--priority none|low|medium|high|urgent] [--estimate <number>|null] [--due-date <yyyy-mm-dd>|null] [--label <label>]... [--project <project>|null] [--parent-id <issue>|null] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--activity] [--full] [--workspace <id>] [--json]
|
||||
orca linear list-issues [--team <team>] [--cycle <cycle>] [--label <label>] [--limit <n>] [--query <text>] [--state <state>] [--cursor <cursor>] [--order-by createdAt|updatedAt] [--project <project>] [--release <release>] [--assignee <user|me|null>] [--delegate <user|me|null>] [--parent-id <issue|null>] [--priority <0-4>] [--created-at <datetime|duration>] [--updated-at <datetime|duration>] [--include-archived] [--workspace <id>|all] [--json]
|
||||
orca linear relation add [<id>] [--current] --related <issue> --type blocks|blocked-by|related|duplicate-of [--workspace <id>] [--json]
|
||||
|
|
@ -80,6 +81,7 @@ orca linear team list [--workspace <id>|all] [--json]
|
|||
orca linear team members --team <key|id> [--workspace <id>] [--json]
|
||||
orca linear team states --team <key|id> [--workspace <id>] [--json]
|
||||
orca linear team labels --team <key|id> [--workspace <id>] [--json]
|
||||
orca linear project list [--query <text>] [--limit <n>] [--workspace <id>|all] [--json]
|
||||
orca linear list [--filter assigned|created|all|completed|open] [--team <key|id>] [--limit <n>] [--workspace <id>|all] [--json]
|
||||
orca linear status set [<id>] [--current] --to <state> [--workspace <id>] [--json]
|
||||
orca linear assignee set [<id>] [--current] (--me | --to-id <userId>) [--workspace <id>] [--json]
|
||||
|
|
@ -95,21 +97,24 @@ orca linear label remove [<id>] [--current] --label <labelId-or-exact-name>... [
|
|||
orca linear label set [<id>] [--current] --label <labelId-or-exact-name>... [--workspace <id>] [--json]
|
||||
orca linear comment add [<id>] [--current] (--body <text> | --body-file <path|->) [--reply-to <commentId>] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear attach [<id>] [--current] --url <url> [--title <title>] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear create --title <title> [--body <text> | --body-file <path|->] [--team <key|id>] [--state <stateId|exact-name>] [--assignee me|<userId>] [--priority none|low|medium|high|urgent] [--estimate <number>] [--due-date <yyyy-mm-dd>] [--label <labelId-or-exact-name>]... [--parent <id> | --parent-current] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear create --title <title> [--body <text> | --body-file <path|->] [--team <key|id>] [--project <projectId-or-exact-name>] [--state <stateId|exact-name>] [--assignee me|<userId>] [--priority none|low|medium|high|urgent] [--estimate <number>] [--due-date <yyyy-mm-dd>] [--label <labelId-or-exact-name>]... [--parent <id> | --parent-current] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
```
|
||||
|
||||
## Discovery And Triage
|
||||
|
||||
Use discovery before mutating fields when you do not already have stable IDs:
|
||||
Use discovery before mutating fields when you do not already have stable IDs. Run only the command for the metadata you need; do not execute the entire block:
|
||||
|
||||
```bash
|
||||
orca linear team list --workspace all --json
|
||||
orca linear team states --team <key-or-id> --workspace <workspaceId> --json
|
||||
orca linear team labels --team <key-or-id> --workspace <workspaceId> --json
|
||||
orca linear team members --team <key-or-id> --workspace <workspaceId> --json
|
||||
orca linear project list --query <project-name> --workspace <workspaceId> --json
|
||||
```
|
||||
|
||||
Prefer IDs for automation. Names are accepted only when they exactly and uniquely match in the issue's team.
|
||||
Prefer IDs for automation. Names are accepted only when they exactly and uniquely match in the relevant team or workspace.
|
||||
|
||||
`save-issue` matches Linear MCP's create-or-update shape: omit an issue target to create, or pass an id/`--current` to update. Repeated labels replace the complete label set. Use the literal `null` to clear assignee, estimate, due date, project, or parent.
|
||||
|
||||
SSH/remoting note: when running through an SSH-backed remote Orca CLI, body files are only supported via stdin (`--body-file -`), not arbitrary remote file paths. Pipe or redirect the body content explicitly.
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ Do not use `orca linear attach` to read screenshots. That command creates link a
|
|||
## Common Commands
|
||||
|
||||
```bash
|
||||
orca linear save-issue [<id>] [--current] [--team <key|id>] [--title <title>] [--description <text> | --body-file <path|->] [--state <state>] [--assignee me|<user>|null] [--priority none|low|medium|high|urgent] [--estimate <number>|null] [--due-date <yyyy-mm-dd>|null] [--label <label>]... [--project <project>|null] [--parent-id <issue>|null] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--activity] [--full] [--workspace <id>] [--json]
|
||||
orca linear list-issues [--team <team>] [--cycle <cycle>] [--label <label>] [--limit <n>] [--query <text>] [--state <state>] [--cursor <cursor>] [--order-by createdAt|updatedAt] [--project <project>] [--release <release>] [--assignee <user|me|null>] [--delegate <user|me|null>] [--parent-id <issue|null>] [--priority <0-4>] [--created-at <datetime|duration>] [--updated-at <datetime|duration>] [--include-archived] [--workspace <id>|all] [--json]
|
||||
orca linear relation add [<id>] [--current] --related <issue> --type blocks|blocked-by|related|duplicate-of [--workspace <id>] [--json]
|
||||
|
|
@ -77,6 +78,7 @@ orca linear team list [--workspace <id>|all] [--json]
|
|||
orca linear team members --team <key|id> [--workspace <id>] [--json]
|
||||
orca linear team states --team <key|id> [--workspace <id>] [--json]
|
||||
orca linear team labels --team <key|id> [--workspace <id>] [--json]
|
||||
orca linear project list [--query <text>] [--limit <n>] [--workspace <id>|all] [--json]
|
||||
orca linear list [--filter assigned|created|all|completed|open] [--team <key|id>] [--limit <n>] [--workspace <id>|all] [--json]
|
||||
orca linear status set [<id>] [--current] --to <state> [--workspace <id>] [--json]
|
||||
orca linear assignee set [<id>] [--current] (--me | --to-id <userId>) [--workspace <id>] [--json]
|
||||
|
|
@ -92,21 +94,24 @@ orca linear label remove [<id>] [--current] --label <labelId-or-exact-name>... [
|
|||
orca linear label set [<id>] [--current] --label <labelId-or-exact-name>... [--workspace <id>] [--json]
|
||||
orca linear comment add [<id>] [--current] (--body <text> | --body-file <path|->) [--reply-to <commentId>] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear attach [<id>] [--current] --url <url> [--title <title>] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear create --title <title> [--body <text> | --body-file <path|->] [--team <key|id>] [--state <stateId|exact-name>] [--assignee me|<userId>] [--priority none|low|medium|high|urgent] [--estimate <number>] [--due-date <yyyy-mm-dd>] [--label <labelId-or-exact-name>]... [--parent <id> | --parent-current] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
orca linear create --title <title> [--body <text> | --body-file <path|->] [--team <key|id>] [--project <projectId-or-exact-name>] [--state <stateId|exact-name>] [--assignee me|<userId>] [--priority none|low|medium|high|urgent] [--estimate <number>] [--due-date <yyyy-mm-dd>] [--label <labelId-or-exact-name>]... [--parent <id> | --parent-current] [--write-id <uuid>] [--workspace <id>] [--json]
|
||||
```
|
||||
|
||||
## Discovery And Triage
|
||||
|
||||
Use discovery before mutating fields when you do not already have stable IDs:
|
||||
Use discovery before mutating fields when you do not already have stable IDs. Run only the command for the metadata you need; do not execute the entire block:
|
||||
|
||||
```bash
|
||||
orca linear team list --workspace all --json
|
||||
orca linear team states --team <key-or-id> --workspace <workspaceId> --json
|
||||
orca linear team labels --team <key-or-id> --workspace <workspaceId> --json
|
||||
orca linear team members --team <key-or-id> --workspace <workspaceId> --json
|
||||
orca linear project list --query <project-name> --workspace <workspaceId> --json
|
||||
```
|
||||
|
||||
Prefer IDs for automation. Names are accepted only when they exactly and uniquely match in the issue's team.
|
||||
Prefer IDs for automation. Names are accepted only when they exactly and uniquely match in the relevant team or workspace.
|
||||
|
||||
`save-issue` matches Linear MCP's create-or-update shape: omit an issue target to create, or pass an id/`--current` to update. Repeated labels replace the complete label set. Use the literal `null` to clear assignee, estimate, due date, project, or parent.
|
||||
|
||||
SSH/remoting note: when running through an SSH-backed remote Orca CLI, body files are only supported via stdin (`--body-file -`), not arbitrary remote file paths. Pipe or redirect the body content explicitly.
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,18 @@
|
|||
import type {
|
||||
LinearSaveIssueRequest,
|
||||
LinearSaveIssueResult
|
||||
} from '../../shared/linear-agent-access'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { printResult } from '../format'
|
||||
import { formatLinearSaveIssue } from '../linear-format'
|
||||
import { buildSaveIssueRequest } from '../linear-save-issue-request'
|
||||
|
||||
const LINEAR_WRITE_TIMEOUT_MS = 75_000
|
||||
|
||||
export const runLinearSaveIssue: CommandHandler = async ({ flags, client, cwd, json }) => {
|
||||
const request: LinearSaveIssueRequest = await buildSaveIssueRequest(flags, cwd, client.isRemote)
|
||||
const response = await client.call<LinearSaveIssueResult>('linear.saveIssue', request, {
|
||||
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
|
||||
})
|
||||
printResult(response, json, formatLinearSaveIssue)
|
||||
}
|
||||
|
|
@ -564,6 +564,46 @@ describe('orca linear CLI handlers', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('maps MCP-style save-issue updates and explicit clears', async () => {
|
||||
queueFixtures(callMock, okFixture('req_save', createResult()))
|
||||
|
||||
await main(
|
||||
[
|
||||
'linear',
|
||||
'save-issue',
|
||||
'ENG-123',
|
||||
'--title',
|
||||
'Updated title',
|
||||
'--assignee',
|
||||
'null',
|
||||
'--estimate',
|
||||
'null',
|
||||
'--due-date',
|
||||
'null',
|
||||
'--project',
|
||||
'null',
|
||||
'--label',
|
||||
'Bug',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'linear.saveIssue',
|
||||
expect.objectContaining({
|
||||
input: 'ENG-123',
|
||||
title: 'Updated title',
|
||||
assignee: null,
|
||||
estimate: null,
|
||||
dueDate: null,
|
||||
project: null,
|
||||
labels: ['Bug']
|
||||
}),
|
||||
{ timeoutMs: 75_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects duplicate body inputs before dispatch', async () => {
|
||||
await main(
|
||||
['linear', 'create', '--title', 'Bug', '--body', 'one', '--body-file', 'body.md'],
|
||||
|
|
|
|||
|
|
@ -66,11 +66,13 @@ import {
|
|||
} from '../linear-format'
|
||||
import { runLinearListIssues } from './linear-list-issues'
|
||||
import { linearRelationWriteHandler } from './linear-relation-write'
|
||||
import { runLinearSaveIssue } from './linear-save-issue'
|
||||
|
||||
const ISSUE_CONTEXT_TIMEOUT_MS = 120_000
|
||||
const LINEAR_WRITE_TIMEOUT_MS = 75_000
|
||||
|
||||
export const LINEAR_HANDLERS: Record<string, CommandHandler> = {
|
||||
'linear save-issue': runLinearSaveIssue,
|
||||
'linear list-issues': runLinearListIssues,
|
||||
'linear relation add': linearRelationWriteHandler('add'),
|
||||
'linear relation remove': linearRelationWriteHandler('remove'),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
LinearIssueContextResult,
|
||||
LinearIssueTaskUpdateResult,
|
||||
LinearIssueRelationWriteResult,
|
||||
LinearSaveIssueResult,
|
||||
LinearProjectListResult,
|
||||
LinearSearchIssueSummary,
|
||||
LinearSearchResult,
|
||||
|
|
@ -157,13 +158,20 @@ export function formatLinearAttach(result: LinearAttachResult): string {
|
|||
return `Attached ${result.attachment.title} to ${result.issue.identifier}${suffix}.`
|
||||
}
|
||||
|
||||
export function formatLinearCreate(result: LinearCreateResult): string {
|
||||
export function formatLinearCreate(result: LinearCreateResult | LinearSaveIssueResult): string {
|
||||
const parent = result.issue.parent ? ` under ${result.issue.parent.identifier}` : ''
|
||||
const project = result.issue.project?.name ? ` in ${result.issue.project.name}` : ''
|
||||
const suffix = result.meta.deduplicated ? ' (already created)' : ''
|
||||
return `Created ${result.issue.identifier}${parent}${project}: ${result.issue.title}${suffix}.`
|
||||
}
|
||||
|
||||
export function formatLinearSaveIssue(result: LinearSaveIssueResult): string {
|
||||
if (result.meta.created) {
|
||||
return formatLinearCreate(result)
|
||||
}
|
||||
return `Saved ${result.issue.identifier}: ${result.issue.title}.`
|
||||
}
|
||||
|
||||
export function formatLinearTaskUpdate(result: LinearIssueTaskUpdateResult): string {
|
||||
const suffix = result.meta.alreadySet ? ' (already set)' : ''
|
||||
return `Updated ${result.issue.identifier} ${taskOperationLabel(result.operation)}${suffix}.`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import type { LinearSaveIssueRequest } from '../shared/linear-agent-access'
|
||||
import {
|
||||
getOptionalNullableNumberFlag,
|
||||
getOptionalStringFlag,
|
||||
getRepeatedStringFlag
|
||||
} from './flags'
|
||||
import {
|
||||
buildLinearCurrentContext,
|
||||
getDueDateFlag,
|
||||
getOptionalWriteId,
|
||||
getPriorityFlag,
|
||||
readLinearBody,
|
||||
rejectAllWorkspaceForWrite
|
||||
} from './linear-request-builders'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
|
||||
export async function buildSaveIssueRequest(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
remote: boolean
|
||||
): Promise<LinearSaveIssueRequest> {
|
||||
rejectAllWorkspaceForWrite(flags)
|
||||
const input = getOptionalStringFlag(flags, 'id')
|
||||
const current = flags.get('current') === true
|
||||
if (input && current) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Pass either <id> or --current, not both')
|
||||
}
|
||||
const body = await readLinearBody(flags, cwd, { required: false })
|
||||
const description = getOptionalStringFlag(flags, 'description')
|
||||
if (body !== undefined && description !== undefined) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Use either --description or --body, not both')
|
||||
}
|
||||
return {
|
||||
input,
|
||||
current,
|
||||
workspaceId: getOptionalStringFlag(flags, 'workspace'),
|
||||
context: buildLinearCurrentContext(cwd, remote),
|
||||
team: getOptionalStringFlag(flags, 'team'),
|
||||
title: getOptionalStringFlag(flags, 'title'),
|
||||
description: description ?? body,
|
||||
state: getOptionalStringFlag(flags, 'state'),
|
||||
assignee: getNullableStringFlag(flags, 'assignee'),
|
||||
priority: flags.has('priority') ? getPriorityFlag(flags, 'priority') : undefined,
|
||||
estimate: getOptionalNullableNumberFlag(flags, 'estimate'),
|
||||
dueDate: flags.has('due-date') ? getNullableDueDateFlag(flags, 'due-date') : undefined,
|
||||
labels: flags.has('label') ? getRepeatedStringFlag(flags, 'label') : undefined,
|
||||
project: getNullableStringFlag(flags, 'project'),
|
||||
parentId: getNullableStringFlag(flags, 'parent-id'),
|
||||
writeId: getOptionalWriteId(flags)
|
||||
}
|
||||
}
|
||||
|
||||
function getNullableStringFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): string | null | undefined {
|
||||
const value = getOptionalStringFlag(flags, name)
|
||||
return value === 'null' ? null : value
|
||||
}
|
||||
|
||||
function getNullableDueDateFlag(flags: Map<string, string | boolean>, name: string): string | null {
|
||||
return getOptionalStringFlag(flags, name) === 'null' ? null : getDueDateFlag(flags, name)
|
||||
}
|
||||
|
|
@ -2,6 +2,43 @@ import type { CommandSpec } from '../args'
|
|||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
export const LINEAR_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['linear', 'save-issue'],
|
||||
summary: 'Create or update a Linear issue',
|
||||
usage:
|
||||
'orca linear save-issue [<id>] [--current] [--team <key|id>] [--title <title>] [--description <text> | --body-file <path|->] [--state <state>] [--assignee me|<user>|null] [--priority none|low|medium|high|urgent] [--estimate <number>|null] [--due-date <yyyy-mm-dd>|null] [--label <label>...] [--project <project>|null] [--parent-id <issue>|null] [--write-id <uuid>] [--workspace <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'current',
|
||||
'team',
|
||||
'title',
|
||||
'description',
|
||||
'body',
|
||||
'body-file',
|
||||
'state',
|
||||
'assignee',
|
||||
'priority',
|
||||
'estimate',
|
||||
'due-date',
|
||||
'label',
|
||||
'project',
|
||||
'parent-id',
|
||||
'write-id',
|
||||
'workspace',
|
||||
'id'
|
||||
],
|
||||
positionalArgs: ['id'],
|
||||
examples: [
|
||||
'orca linear save-issue --team ENG --title "Fix auth" --priority high --json',
|
||||
'orca linear save-issue ENG-123 --title "Fix OAuth callback" --assignee me --json',
|
||||
'orca linear save-issue --current --project null --due-date null --json'
|
||||
],
|
||||
notes: [
|
||||
'Without <id> or --current, creates an issue and requires --team and --title.',
|
||||
'Labels replace the complete label set, matching Linear MCP save_issue semantics.',
|
||||
'Use the literal null to clear assignee, estimate, due date, project, or parent.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['linear', 'list-issues'],
|
||||
summary: 'List Linear issues with MCP-compatible filters',
|
||||
|
|
|
|||
|
|
@ -1270,6 +1270,9 @@ export async function updateIssue(
|
|||
if (updates.projectId !== undefined) {
|
||||
payload.projectId = updates.projectId
|
||||
}
|
||||
if (updates.parentId !== undefined) {
|
||||
payload.parentId = updates.parentId
|
||||
}
|
||||
|
||||
const result = await entry.client.updateIssue(id, payload)
|
||||
if (!result.success) {
|
||||
|
|
@ -1290,10 +1293,7 @@ export async function updateIssue(
|
|||
|
||||
export async function updateIssueForAgent(
|
||||
id: string,
|
||||
updates: Pick<
|
||||
LinearIssueUpdate,
|
||||
'stateId' | 'assigneeId' | 'priority' | 'estimate' | 'dueDate' | 'labelIds'
|
||||
>,
|
||||
updates: LinearIssueUpdate,
|
||||
workspaceId: string,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
): Promise<LinearIssueWriteRecord> {
|
||||
|
|
@ -1307,6 +1307,12 @@ export async function updateIssueForAgent(
|
|||
if (updates.stateId !== undefined) {
|
||||
payload.stateId = updates.stateId
|
||||
}
|
||||
if (updates.title !== undefined) {
|
||||
payload.title = updates.title
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
payload.description = updates.description
|
||||
}
|
||||
if (updates.assigneeId !== undefined) {
|
||||
payload.assigneeId = updates.assigneeId
|
||||
}
|
||||
|
|
@ -1322,6 +1328,12 @@ export async function updateIssueForAgent(
|
|||
if (updates.labelIds !== undefined) {
|
||||
payload.labelIds = updates.labelIds
|
||||
}
|
||||
if (updates.projectId !== undefined) {
|
||||
payload.projectId = updates.projectId
|
||||
}
|
||||
if (updates.parentId !== undefined) {
|
||||
payload.parentId = updates.parentId
|
||||
}
|
||||
const result = await client.updateIssue(id, payload)
|
||||
if (!result.success) {
|
||||
throw new LinearWriteFailure('failed', 'Linear update failed')
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ type TeamLabelNode = {
|
|||
type TeamMemberNode = {
|
||||
id: string
|
||||
displayName: string
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
|
|
@ -89,6 +91,8 @@ export async function fetchAllTeamMembers(team: {
|
|||
return members.nodes.map((m) => ({
|
||||
id: m.id,
|
||||
displayName: m.displayName,
|
||||
name: m.name ?? undefined,
|
||||
email: m.email ?? undefined,
|
||||
avatarUrl: m.avatarUrl ?? undefined
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ type LinearUserNode = {
|
|||
|
||||
type LinearProjectNode = {
|
||||
id: string
|
||||
slugId?: string | null
|
||||
name: string
|
||||
description?: string | null
|
||||
content?: string | null
|
||||
|
|
@ -188,6 +189,7 @@ export type LinearProjectCreateInput = {
|
|||
|
||||
const ORCA_PROJECT_FIELDS = `
|
||||
id
|
||||
slugId
|
||||
name
|
||||
description
|
||||
content
|
||||
|
|
@ -605,6 +607,7 @@ function mapProjectForWorkspace(
|
|||
): LinearProjectSummary {
|
||||
return {
|
||||
id: project.id,
|
||||
slugId: project.slugId ?? undefined,
|
||||
workspaceId: entry.workspace.id,
|
||||
workspaceName: entry.workspace.organizationName,
|
||||
name: project.name,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ type LabelNode = {
|
|||
type MemberNode = {
|
||||
id: string
|
||||
displayName: string
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
|
|
@ -244,7 +246,7 @@ describe('Linear teams', () => {
|
|||
.fn()
|
||||
.mockResolvedValue(
|
||||
makeConnection([
|
||||
[makeMember('user-1', 'Ada')],
|
||||
[{ ...makeMember('user-1', 'Ada'), name: 'Ada Lovelace', email: 'ada@example.com' }],
|
||||
[makeMember('user-2', 'Grace')],
|
||||
[makeMember('user-3', 'Linus')]
|
||||
])
|
||||
|
|
@ -254,9 +256,27 @@ describe('Linear teams', () => {
|
|||
const { getTeamMembersOrThrow } = await import('./teams')
|
||||
|
||||
await expect(getTeamMembersOrThrow('team-1', 'workspace-1')).resolves.toEqual([
|
||||
{ id: 'user-1', displayName: 'Ada', avatarUrl: undefined },
|
||||
{ id: 'user-2', displayName: 'Grace', avatarUrl: undefined },
|
||||
{ id: 'user-3', displayName: 'Linus', avatarUrl: undefined }
|
||||
{
|
||||
id: 'user-1',
|
||||
displayName: 'Ada',
|
||||
name: 'Ada Lovelace',
|
||||
email: 'ada@example.com',
|
||||
avatarUrl: undefined
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
displayName: 'Grace',
|
||||
name: undefined,
|
||||
email: undefined,
|
||||
avatarUrl: undefined
|
||||
},
|
||||
{
|
||||
id: 'user-3',
|
||||
displayName: 'Linus',
|
||||
name: undefined,
|
||||
email: undefined,
|
||||
avatarUrl: undefined
|
||||
}
|
||||
])
|
||||
|
||||
expect(entry.client.team).toHaveBeenCalledWith('team-1')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LINEAR_WRITE_BODY_CAP } from '../../shared/linear-agent-access'
|
||||
import * as linearTeams from '../linear/teams'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const issue = {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-1',
|
||||
title: 'Existing title',
|
||||
description: 'Existing description',
|
||||
url: 'https://linear.app/acme/issue/ENG-1',
|
||||
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
|
||||
state: { id: 'state-1', name: 'Todo' },
|
||||
parent: null,
|
||||
project: null,
|
||||
assignee: null,
|
||||
priority: 0,
|
||||
estimate: null,
|
||||
dueDate: null,
|
||||
labelIds: [],
|
||||
labels: []
|
||||
}
|
||||
|
||||
type SaveIssueInternals = {
|
||||
resolveLinearAssignee(input: string, teamId: string, workspaceId: string): Promise<string>
|
||||
resolveLinearAgentState(input: string, states: unknown[]): unknown | null
|
||||
buildLinearSaveUpdate(
|
||||
params: { labels?: string[] },
|
||||
current: typeof issue,
|
||||
workspaceId: string
|
||||
): Promise<{ labelIds?: string[] }>
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Linear save issue', () => {
|
||||
it('delegates creates with the MCP-required team and title', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const create = vi.spyOn(runtime, 'linearIssueCreate').mockResolvedValue({
|
||||
issue,
|
||||
meta: { workspaceId: 'workspace-1', writeId: 'write-1', deduplicated: false }
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.linearSaveIssue({ team: 'ENG', title: 'New issue', workspaceId: 'workspace-1' })
|
||||
).resolves.toMatchObject({ meta: { created: true } })
|
||||
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamInput: 'ENG',
|
||||
title: 'New issue',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps team changes explicitly unsupported on updates', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
|
||||
await expect(
|
||||
runtime.linearSaveIssue({ input: 'ENG-1', team: 'OPS', title: 'Moved issue' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'linear_write_failed',
|
||||
message: 'Team can only be set when creating an issue.'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects oversized descriptions before resolving an issue or calling Linear', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const resolveTarget = vi.fn()
|
||||
Object.assign(runtime, { resolveLinearAgentWriteTarget: resolveTarget })
|
||||
|
||||
await expect(
|
||||
runtime.linearSaveIssue({
|
||||
input: 'ENG-1',
|
||||
description: 'x'.repeat(LINEAR_WRITE_BODY_CAP + 1)
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'linear_body_too_large' })
|
||||
expect(resolveTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not send a mutation or confirmation read when every field is already set', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const runWrite = vi.fn()
|
||||
const notify = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(runtime, {
|
||||
resolveLinearAgentWriteTarget: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ issue, workspaceId: 'workspace-1' }),
|
||||
readLinearAgentIssueWriteRecord: vi.fn().mockResolvedValue(issue),
|
||||
buildLinearSaveUpdate: vi.fn().mockResolvedValue({ title: issue.title }),
|
||||
runLinearAgentWrite: runWrite,
|
||||
notifyLinearLinkedIssueUpdated: notify
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.linearSaveIssue({ input: issue.identifier, title: issue.title })
|
||||
).resolves.toMatchObject({ issue, meta: { created: false } })
|
||||
|
||||
expect(runWrite).not.toHaveBeenCalled()
|
||||
expect(notify).toHaveBeenCalledWith('workspace-1', issue.identifier)
|
||||
})
|
||||
|
||||
it('accepts user UUIDs without listing every team member', async () => {
|
||||
const runtime = new OrcaRuntimeService() as unknown as SaveIssueInternals
|
||||
const listMembers = vi.spyOn(linearTeams, 'getTeamMembersOrThrow')
|
||||
const userId = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
await expect(runtime.resolveLinearAssignee(userId, 'team-1', 'workspace-1')).resolves.toBe(
|
||||
userId
|
||||
)
|
||||
expect(listMembers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('matches assignees by full name or email like Linear MCP', async () => {
|
||||
const runtime = new OrcaRuntimeService() as unknown as SaveIssueInternals
|
||||
vi.spyOn(linearTeams, 'getTeamMembersOrThrow').mockResolvedValue([
|
||||
{
|
||||
id: 'user-1',
|
||||
displayName: 'Ada',
|
||||
name: 'Ada Lovelace',
|
||||
email: 'ada@example.com'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(
|
||||
runtime.resolveLinearAssignee('Ada Lovelace', 'team-1', 'workspace-1')
|
||||
).resolves.toBe('user-1')
|
||||
await expect(
|
||||
runtime.resolveLinearAssignee('ADA@EXAMPLE.COM', 'team-1', 'workspace-1')
|
||||
).resolves.toBe('user-1')
|
||||
})
|
||||
|
||||
it('resolves workflow lifecycle types while preferring exact state names', () => {
|
||||
const runtime = new OrcaRuntimeService() as unknown as SaveIssueInternals
|
||||
const states = [
|
||||
{ id: 'state-progress', name: 'In Progress', type: 'started' },
|
||||
{ id: 'state-started', name: 'Started', type: 'unstarted' }
|
||||
]
|
||||
|
||||
expect(runtime.resolveLinearAgentState('started', states)).toBe(states[1])
|
||||
expect(runtime.resolveLinearAgentState('unstarted', states)).toBe(states[1])
|
||||
})
|
||||
|
||||
it('clears labels without listing the team label catalog', async () => {
|
||||
const runtime = new OrcaRuntimeService() as unknown as SaveIssueInternals
|
||||
const listLabels = vi.spyOn(linearTeams, 'getTeamLabelsOrThrow')
|
||||
|
||||
await expect(
|
||||
runtime.buildLinearSaveUpdate({ labels: [] }, issue, 'workspace-1')
|
||||
).resolves.toEqual({ labelIds: [] })
|
||||
expect(listLabels).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ describe('Linear agent access RPC methods', () => {
|
|||
linearIssueUpdateTask: vi.fn().mockResolvedValue({ ok: true }),
|
||||
linearIssueAddComment: vi.fn().mockResolvedValue({ ok: true }),
|
||||
linearIssueAttachLink: vi.fn().mockResolvedValue({ ok: true }),
|
||||
linearSaveIssue: vi.fn().mockResolvedValue({ ok: true }),
|
||||
linearIssueCreate: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS })
|
||||
|
|
@ -139,6 +140,16 @@ describe('Linear agent access RPC methods', () => {
|
|||
workspaceId: 'workspace-1'
|
||||
})
|
||||
)
|
||||
const saveResponse = await dispatcher.dispatch(
|
||||
makeRequest('linear.saveIssue', {
|
||||
input: 'ENG-1',
|
||||
title: 'Updated title',
|
||||
assignee: null,
|
||||
labels: ['Bug'],
|
||||
project: null,
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
)
|
||||
|
||||
expect(setStateResponse.ok).toBe(true)
|
||||
expect(teamListResponse.ok).toBe(true)
|
||||
|
|
@ -152,6 +163,7 @@ describe('Linear agent access RPC methods', () => {
|
|||
expect(commentResponse.ok).toBe(true)
|
||||
expect(attachResponse.ok).toBe(true)
|
||||
expect(createResponse.ok).toBe(true)
|
||||
expect(saveResponse.ok).toBe(true)
|
||||
expect(runtime.linearIssueSetState).toHaveBeenCalledWith({
|
||||
input: 'ENG-1',
|
||||
to: 'In Review',
|
||||
|
|
@ -218,6 +230,15 @@ describe('Linear agent access RPC methods', () => {
|
|||
writeId: '33333333-3333-4333-8333-333333333333',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
expect(runtime.linearSaveIssue).toHaveBeenCalledWith({
|
||||
input: 'ENG-1',
|
||||
title: 'Updated title',
|
||||
assignee: null,
|
||||
labels: ['Bug'],
|
||||
project: null,
|
||||
workspaceId: 'workspace-1',
|
||||
writeId: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed write ids before the runtime is called', async () => {
|
||||
|
|
@ -336,6 +357,7 @@ type LinearUnconfirmedBuilder = {
|
|||
): Error & { data?: { cause?: string; nextSteps?: string[] } }
|
||||
resolveLinearAgentState(input: string, states: unknown[]): unknown | null
|
||||
linearCreatedIssueMatchesIntent(issue: unknown, intent: unknown): boolean
|
||||
linearSavedIssueMatchesIntent(issue: unknown, intent: unknown): boolean
|
||||
notifyLinearLinkedIssueUpdated(
|
||||
workspaceId: string,
|
||||
identifier: string | readonly string[]
|
||||
|
|
@ -560,6 +582,43 @@ describe('Linear agent write recovery helpers', () => {
|
|||
).toBe(false)
|
||||
})
|
||||
|
||||
it('confirms save-issue updates including explicit relationship clears', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const builder = runtime as unknown as LinearUnconfirmedBuilder
|
||||
const issue = {
|
||||
id: 'issue-2',
|
||||
identifier: 'ENG-2',
|
||||
title: 'Updated title',
|
||||
description: 'Updated description',
|
||||
url: 'https://example.invalid/ENG-2',
|
||||
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
|
||||
state: { id: 'state-review', name: 'In Review' },
|
||||
parent: null,
|
||||
project: null,
|
||||
assignee: null,
|
||||
priority: 2,
|
||||
estimate: null,
|
||||
dueDate: null,
|
||||
labelIds: ['label-1']
|
||||
}
|
||||
|
||||
expect(
|
||||
builder.linearSavedIssueMatchesIntent(issue, {
|
||||
title: 'Updated title',
|
||||
description: 'Updated description',
|
||||
stateId: 'state-review',
|
||||
parentId: null,
|
||||
projectId: null,
|
||||
assigneeId: null,
|
||||
priority: 2,
|
||||
estimate: null,
|
||||
dueDate: null,
|
||||
labelIds: ['label-1']
|
||||
})
|
||||
).toBe(true)
|
||||
expect(builder.linearSavedIssueMatchesIntent(issue, { projectId: 'project-2' })).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves workflow states by UUID or case-insensitive exact name', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const states = [
|
||||
|
|
|
|||
|
|
@ -130,6 +130,21 @@ const LinearIssueCreate = z.object({
|
|||
context: LinearCurrentContext
|
||||
})
|
||||
|
||||
const LinearSaveIssue = LinearWriteTarget.extend({
|
||||
team: OptionalString,
|
||||
title: OptionalString,
|
||||
description: z.string().optional(),
|
||||
state: OptionalString,
|
||||
assignee: z.string().nullable().optional(),
|
||||
priority: z.number().int().min(0).max(4).optional(),
|
||||
estimate: z.number().min(0).nullable().optional(),
|
||||
dueDate: OptionalLinearDueDateOrClear,
|
||||
labels: z.array(z.string()).optional(),
|
||||
project: z.string().nullable().optional(),
|
||||
parentId: z.string().nullable().optional(),
|
||||
writeId: OptionalString
|
||||
})
|
||||
|
||||
function parseLinearWriteId(writeId: string | undefined): string | undefined {
|
||||
if (writeId === undefined) {
|
||||
return undefined
|
||||
|
|
@ -141,6 +156,12 @@ function parseLinearWriteId(writeId: string | undefined): string | undefined {
|
|||
}
|
||||
|
||||
export const LINEAR_AGENT_ACCESS_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'linear.saveIssue',
|
||||
params: LinearSaveIssue,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearSaveIssue({ ...params, writeId: parseLinearWriteId(params.writeId) })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.agentSearchIssues',
|
||||
params: AgentSearchIssues,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,29 @@ describe('Linear agent project access helpers', () => {
|
|||
expect(readExactName).toHaveBeenCalledWith('launch', 'workspace-1')
|
||||
})
|
||||
|
||||
it('resolves Linear projects by URL slug without a paginated exact-name scan', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const tester = runtime as unknown as LinearProjectResolverTester
|
||||
vi.spyOn(tester, 'readLinearProjectByIdForCreate').mockResolvedValue(null as never)
|
||||
vi.spyOn(tester, 'readLinearProjectsForCreate').mockResolvedValue([
|
||||
{
|
||||
id: 'project-1',
|
||||
slugId: 'launch-d8f6c7e7',
|
||||
name: 'Launch',
|
||||
teams: [{ id: 'team-1', name: 'Engineering', key: 'ENG' }]
|
||||
}
|
||||
] as never)
|
||||
const readExactName = vi.spyOn(tester, 'readLinearProjectsByExactNameForCreate')
|
||||
|
||||
await expect(
|
||||
tester.resolveLinearCreateProject('LAUNCH-D8F6C7E7', {
|
||||
id: 'team-1',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
).resolves.toMatchObject({ id: 'project-1' })
|
||||
expect(readExactName).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves same-named Linear projects by target team compatibility', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const tester = runtime as unknown as LinearProjectResolverTester
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ import type {
|
|||
LinearStatusSetResult,
|
||||
LinearCommentAddResult,
|
||||
LinearAttachResult,
|
||||
LinearCreateResult
|
||||
LinearCreateResult,
|
||||
LinearSaveIssueResult
|
||||
} from '../../shared/linear-agent-access'
|
||||
import {
|
||||
formatLinearProjectListRows,
|
||||
|
|
@ -24,6 +25,7 @@ import {
|
|||
isLinearAttachResult,
|
||||
isLinearCommentAddResult,
|
||||
isLinearCreateResult,
|
||||
isLinearSaveIssueResult,
|
||||
isLinearIssueContextResult,
|
||||
isLinearIssueListResult,
|
||||
isLinearMcpIssueListResult,
|
||||
|
|
@ -93,6 +95,9 @@ export function formatRemoteLinearCli(result: unknown): { stdout: string; stderr
|
|||
if (isLinearAttachResult(result)) {
|
||||
return { stdout: `${formatLinearAttach(result)}\n`, stderr: '' }
|
||||
}
|
||||
if (isLinearSaveIssueResult(result)) {
|
||||
return { stdout: `${formatLinearSaveIssue(result)}\n`, stderr: '' }
|
||||
}
|
||||
if (isLinearCreateResult(result)) {
|
||||
return { stdout: `${formatLinearCreate(result)}\n`, stderr: '' }
|
||||
}
|
||||
|
|
@ -133,6 +138,12 @@ function formatLinearIssue(result: LinearIssueContextResult): string {
|
|||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function formatLinearSaveIssue(result: LinearSaveIssueResult): string {
|
||||
return result.meta.created
|
||||
? formatLinearCreate(result)
|
||||
: `Saved ${result.issue.identifier}: ${result.issue.title}.`
|
||||
}
|
||||
|
||||
function formatLinearIssueRows(issues: LinearSearchIssueSummary[]): string {
|
||||
if (issues.length === 0) {
|
||||
return 'No Linear issues found.'
|
||||
|
|
@ -231,7 +242,7 @@ function formatLinearAttach(result: LinearAttachResult): string {
|
|||
return `Attached ${result.attachment.title} to ${result.issue.identifier}${suffix}.`
|
||||
}
|
||||
|
||||
function formatLinearCreate(result: LinearCreateResult): string {
|
||||
function formatLinearCreate(result: LinearCreateResult | LinearSaveIssueResult): string {
|
||||
const parent = result.issue.parent ? ` under ${result.issue.parent.identifier}` : ''
|
||||
const project = result.issue.project?.name ? ` in ${result.issue.project.name}` : ''
|
||||
const suffix = result.meta.deduplicated ? ' (already created)' : ''
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ const LINEAR_HELP = `orca linear
|
|||
Usage: orca linear <command> [options]
|
||||
|
||||
Commands:
|
||||
save-issue Create or update a Linear issue
|
||||
list-issues List Linear issues with MCP-compatible filters
|
||||
relation add Add a Linear issue relation
|
||||
relation remove Remove a Linear issue relation
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type {
|
|||
LinearAttachResult,
|
||||
LinearCommentAddResult,
|
||||
LinearCreateResult,
|
||||
LinearSaveIssueResult,
|
||||
LinearIssueContextResult,
|
||||
LinearIssueListResult,
|
||||
LinearMcpIssueListResult,
|
||||
|
|
@ -162,6 +163,17 @@ export function isLinearCreateResult(result: unknown): result is LinearCreateRes
|
|||
)
|
||||
}
|
||||
|
||||
export function isLinearSaveIssueResult(result: unknown): result is LinearSaveIssueResult {
|
||||
return (
|
||||
isRecord(result) &&
|
||||
isRecord(result.issue) &&
|
||||
isRecord(result.meta) &&
|
||||
typeof result.issue.identifier === 'string' &&
|
||||
typeof result.issue.title === 'string' &&
|
||||
typeof result.meta.created === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
|
||||
|
||||
function createRuntime() {
|
||||
const linearSaveIssue = vi.fn(async (request: unknown) => ({
|
||||
request,
|
||||
issue: {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-123',
|
||||
title: 'Updated title',
|
||||
url: 'https://linear.app/acme/issue/ENG-123',
|
||||
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
|
||||
state: { id: 'state-1', name: 'Todo' },
|
||||
parent: null
|
||||
},
|
||||
meta: { workspaceId: 'workspace-1', created: false }
|
||||
}))
|
||||
return {
|
||||
runtime: {
|
||||
getRuntimeId: () => 'runtime-test',
|
||||
linearSaveIssue
|
||||
} as unknown as OrcaRuntimeService,
|
||||
linearSaveIssue
|
||||
}
|
||||
}
|
||||
|
||||
describe('SSH remote Linear save issue', () => {
|
||||
it('forwards update fields, clears, stdin, and SSH context without losing types', async () => {
|
||||
const { runtime, linearSaveIssue } = createRuntime()
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: [
|
||||
'linear',
|
||||
'save-issue',
|
||||
'ENG-123',
|
||||
'--body-file',
|
||||
'-',
|
||||
'--assignee',
|
||||
'null',
|
||||
'--estimate',
|
||||
'null',
|
||||
'--due-date',
|
||||
'null',
|
||||
'--label',
|
||||
'Bug',
|
||||
'--label',
|
||||
'Regression',
|
||||
'--json'
|
||||
],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: {
|
||||
ORCA_TERMINAL_HANDLE: 'term_ssh',
|
||||
ORCA_WORKTREE_ID: 'repo::remote'
|
||||
},
|
||||
stdin: 'Updated description'
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(linearSaveIssue).toHaveBeenCalledWith({
|
||||
input: 'ENG-123',
|
||||
current: false,
|
||||
workspaceId: undefined,
|
||||
context: {
|
||||
remote: true,
|
||||
terminalHandle: 'term_ssh',
|
||||
worktreeId: 'repo::remote'
|
||||
},
|
||||
team: undefined,
|
||||
title: undefined,
|
||||
description: 'Updated description',
|
||||
state: undefined,
|
||||
assignee: null,
|
||||
priority: undefined,
|
||||
estimate: null,
|
||||
dueDate: null,
|
||||
labels: ['Bug', 'Regression'],
|
||||
project: undefined,
|
||||
parentId: undefined,
|
||||
writeId: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards the team and title required for create mode', async () => {
|
||||
const { runtime, linearSaveIssue } = createRuntime()
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'save-issue', '--team', 'ENG', '--title', 'New issue', '--json'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(linearSaveIssue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ input: undefined, current: false, team: 'ENG', title: 'New issue' })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects remote body paths instead of reading from the wrong filesystem', async () => {
|
||||
const { runtime, linearSaveIssue } = createRuntime()
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'save-issue', 'ENG-123', '--body-file', 'body.md', '--json'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(linearSaveIssue).not.toHaveBeenCalled()
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'invalid_argument' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
import type { RpcDispatcher } from '../runtime/rpc/dispatcher'
|
||||
import {
|
||||
RemoteLinearWriteArgumentError,
|
||||
buildRemoteContext,
|
||||
call,
|
||||
dueDateFlag,
|
||||
nonNegativeIntegerFlag,
|
||||
optionalString,
|
||||
optionalWriteId,
|
||||
priorityFlag,
|
||||
readRemoteBody,
|
||||
repeatedString,
|
||||
rejectAllWorkspaceForWrite,
|
||||
validateLinearRemoteArgs
|
||||
} from './ssh-remote-linear-write-support'
|
||||
|
||||
type ParsedRemoteCli = {
|
||||
commandPath: string[]
|
||||
flags: Map<string, string | boolean>
|
||||
}
|
||||
|
||||
const LINEAR_SAVE_ISSUE_FLAGS = new Set([
|
||||
'help',
|
||||
'json',
|
||||
'pairing-code',
|
||||
'environment',
|
||||
'workspace',
|
||||
'current',
|
||||
'id',
|
||||
'team',
|
||||
'title',
|
||||
'description',
|
||||
'body',
|
||||
'body-file',
|
||||
'state',
|
||||
'assignee',
|
||||
'priority',
|
||||
'estimate',
|
||||
'due-date',
|
||||
'label',
|
||||
'project',
|
||||
'parent-id',
|
||||
'write-id'
|
||||
])
|
||||
|
||||
export async function dispatchRemoteLinearSaveIssue(
|
||||
dispatcher: RpcDispatcher,
|
||||
parsed: ParsedRemoteCli,
|
||||
env: Record<string, string>,
|
||||
stdin?: string
|
||||
): Promise<RpcResponse> {
|
||||
validateLinearRemoteArgs(parsed, LINEAR_SAVE_ISSUE_FLAGS, ['linear', 'save-issue'], 1, 'id')
|
||||
rejectAllWorkspaceForWrite(parsed.flags)
|
||||
const body = readRemoteBody(parsed.flags, false, stdin)
|
||||
const description = optionalString(parsed.flags, 'description')
|
||||
if (body !== undefined && description !== undefined) {
|
||||
throw new RemoteLinearWriteArgumentError(
|
||||
'invalid_argument',
|
||||
'Use either --description or --body, not both'
|
||||
)
|
||||
}
|
||||
return await call(dispatcher, 'linear.saveIssue', {
|
||||
...buildOptionalRemoteTargetRequest(parsed, env),
|
||||
team: optionalString(parsed.flags, 'team'),
|
||||
title: optionalString(parsed.flags, 'title'),
|
||||
description: description ?? body,
|
||||
state: optionalString(parsed.flags, 'state'),
|
||||
assignee: nullableString(parsed.flags, 'assignee'),
|
||||
priority: parsed.flags.has('priority') ? priorityFlag(parsed.flags, 'priority') : undefined,
|
||||
estimate: nullableNonNegativeInteger(parsed.flags, 'estimate'),
|
||||
dueDate: nullableDueDate(parsed.flags, 'due-date'),
|
||||
labels: parsed.flags.has('label') ? repeatedString(parsed.flags, 'label') : undefined,
|
||||
project: nullableString(parsed.flags, 'project'),
|
||||
parentId: nullableString(parsed.flags, 'parent-id'),
|
||||
writeId: optionalWriteId(parsed.flags)
|
||||
})
|
||||
}
|
||||
|
||||
function buildOptionalRemoteTargetRequest(
|
||||
parsed: ParsedRemoteCli,
|
||||
env: Record<string, string>
|
||||
): Record<string, unknown> {
|
||||
const input = optionalString(parsed.flags, 'id') ?? parsed.commandPath.slice(2).join(' ').trim()
|
||||
const current = parsed.flags.get('current') === true
|
||||
if (input && current) {
|
||||
throw new RemoteLinearWriteArgumentError(
|
||||
'invalid_argument',
|
||||
'Pass either <id> or --current, not both'
|
||||
)
|
||||
}
|
||||
return {
|
||||
input: input || undefined,
|
||||
current,
|
||||
workspaceId: optionalString(parsed.flags, 'workspace'),
|
||||
context: buildRemoteContext(env)
|
||||
}
|
||||
}
|
||||
|
||||
function nullableString(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): string | null | undefined {
|
||||
const value = optionalString(flags, name)
|
||||
return value === 'null' ? null : value
|
||||
}
|
||||
|
||||
function nullableNonNegativeInteger(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): number | null | undefined {
|
||||
const value = optionalString(flags, name)
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return value === 'null' ? null : nonNegativeIntegerFlag(flags, name)
|
||||
}
|
||||
|
||||
function nullableDueDate(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): string | null | undefined {
|
||||
const value = optionalString(flags, name)
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return value === 'null' ? null : dueDateFlag(flags, name)
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import type { RpcResponse } from '../runtime/rpc/core'
|
|||
import type { RpcDispatcher } from '../runtime/rpc/dispatcher'
|
||||
import { getRemoteLinearWriteHelp } from './ssh-remote-linear-write-help'
|
||||
import { dispatchRemoteLinearRelationWrite } from './ssh-remote-linear-relation-write'
|
||||
import { dispatchRemoteLinearSaveIssue } from './ssh-remote-linear-save-issue'
|
||||
import {
|
||||
RemoteLinearWriteArgumentError,
|
||||
buildRemoteContext,
|
||||
|
|
@ -60,13 +61,15 @@ const LINEAR_CREATE_FLAGS = new Set([
|
|||
'parent-current',
|
||||
'write-id'
|
||||
])
|
||||
|
||||
export async function tryDispatchRemoteLinearWriteCli(
|
||||
dispatcher: RpcDispatcher,
|
||||
parsed: ParsedRemoteCli,
|
||||
env: Record<string, string>,
|
||||
stdin?: string
|
||||
): Promise<RpcResponse | null> {
|
||||
if (isRemoteCommand(parsed, 'linear', 'save-issue')) {
|
||||
return await dispatchRemoteLinearSaveIssue(dispatcher, parsed, env, stdin)
|
||||
}
|
||||
if (isRemoteCommand(parsed, 'linear', 'relation', 'add')) {
|
||||
return await dispatchRemoteLinearRelationWrite(dispatcher, parsed, env, 'add')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ function matchesRemoteCommand(commandPath: string[], ...command: string[]): bool
|
|||
|
||||
export function getRemoteLinearWriteHelp(parsed: ParsedRemoteCli): string | null {
|
||||
const path = parsed.commandPath
|
||||
if (matchesRemoteCommand(path, 'linear', 'save-issue')) {
|
||||
return LINEAR_SAVE_ISSUE_HELP
|
||||
}
|
||||
if (matchesRemoteCommand(path, 'linear', 'relation', 'add')) {
|
||||
return LINEAR_RELATION_ADD_HELP
|
||||
}
|
||||
|
|
@ -69,6 +72,7 @@ export function getRemoteLinearWriteHelp(parsed: ParsedRemoteCli): string | null
|
|||
return null
|
||||
}
|
||||
|
||||
const LINEAR_SAVE_ISSUE_HELP = `orca linear save-issue\n\nUsage: orca linear save-issue [<id>] [--current] [--team <key|id>] [--title <title>] [--description <text> | --body-file -] [--state <state>] [--assignee me|<user>|null] [--priority none|low|medium|high|urgent] [--estimate <number>|null] [--due-date <yyyy-mm-dd>|null] [--label <label>...] [--project <project>|null] [--parent-id <issue>|null] [--write-id <uuid>] [--workspace <id>] [--json]\n\nCreate or update a Linear issue`
|
||||
const LINEAR_RELATION_ADD_HELP = `orca linear relation add\n\nUsage: orca linear relation add [<id>] [--current] --related <issue> --type blocks|blocked-by|related|duplicate-of [--workspace <id>] [--json]\n\nAdd a Linear issue relation`
|
||||
const LINEAR_RELATION_REMOVE_HELP = `orca linear relation remove\n\nUsage: orca linear relation remove [<id>] [--current] --related <issue> --type blocks|blocked-by|related|duplicate-of [--workspace <id>] [--json]\n\nRemove a Linear issue relation`
|
||||
|
||||
|
|
|
|||
|
|
@ -90,7 +90,8 @@ export type {
|
|||
LinearUserSummary,
|
||||
LinearWorkspaceCandidate,
|
||||
LinearWriteIssueRef,
|
||||
LinearIssueTaskUpdateResult
|
||||
LinearIssueTaskUpdateResult,
|
||||
LinearSaveIssueResult
|
||||
} from './linear-agent-result-types'
|
||||
export type { LinearIssueActivityEntry, LinearIssueActivityValue } from './linear-issue-activity'
|
||||
export type { LinearInlineMedia } from './linear-inline-media'
|
||||
|
|
@ -174,6 +175,21 @@ export type LinearCreateRequest = {
|
|||
context?: LinearCurrentIssueContextHints
|
||||
}
|
||||
|
||||
export type LinearSaveIssueRequest = LinearWriteTargetRequest & {
|
||||
team?: string
|
||||
title?: string
|
||||
description?: string
|
||||
state?: string
|
||||
assignee?: string | null
|
||||
priority?: number
|
||||
estimate?: number | null
|
||||
dueDate?: string | null
|
||||
labels?: string[]
|
||||
project?: string | null
|
||||
parentId?: string | null
|
||||
writeId?: string
|
||||
}
|
||||
|
||||
export function clampLinearSearchLimit(limit: number | undefined): number {
|
||||
if (limit === undefined) {
|
||||
return LINEAR_SEARCH_DEFAULT_LIMIT
|
||||
|
|
|
|||
|
|
@ -313,3 +313,13 @@ export type LinearCreateResult = {
|
|||
}
|
||||
meta: { workspaceId: string; writeId: string; deduplicated: boolean }
|
||||
}
|
||||
|
||||
export type LinearSaveIssueResult = {
|
||||
issue: LinearCreateResult['issue']
|
||||
meta: {
|
||||
workspaceId: string
|
||||
created: boolean
|
||||
writeId?: string
|
||||
deduplicated?: boolean
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1687,6 +1687,7 @@ export type LinearIssue = {
|
|||
|
||||
export type LinearProjectSummary = {
|
||||
id: string
|
||||
slugId?: string
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
name: string
|
||||
|
|
@ -1851,6 +1852,7 @@ export type LinearIssueUpdate = {
|
|||
dueDate?: string | null
|
||||
labelIds?: string[]
|
||||
projectId?: string | null
|
||||
parentId?: string | null
|
||||
}
|
||||
|
||||
export type ClassifiedError = {
|
||||
|
|
@ -2026,6 +2028,8 @@ export type LinearLabel = {
|
|||
export type LinearMember = {
|
||||
id: string
|
||||
displayName: string
|
||||
name?: string
|
||||
email?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue