mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
feat(linear): project mutations and richer issue verbs in CLI (#288)
## What this adds Implements the request in #287 and the Tier 1 audit gaps for the `linear` skill's `scripts/linear` CLI, reconciled against the live Linear GraphQL schema. ### New verbs - `linear project update` — name, description, status, start/target dates, priority, with the same `--dry-run`/`--confirm` gate as issue mutations, and a local 255-character description guard matching Linear's `projectUpdate` limit (Linear rejects longer descriptions with a generic error). - `linear issue archive` / `linear issue unarchive` — both gated, returning `IssueArchivePayload.entity`. - `linear state list --team ENG` — first-class workflow-state discovery (previously states were only visible in the `issue move` failure path). ### Richer issue verbs - `issue create` now accepts `--project`, `--parent`, `--assignee`, `--label` (repeatable), `--state`, `--due`. - `issue update` now accepts `--assignee`, `--label` (add), `--remove-label`, `--due`, `--project`. ### Resolution rules (all require exactly one match, mirroring `resolve_team`) - Project: UUID or exact name - Parent: issue identifier or UUID - Assignee: exact name, display name, or email (via `users`) - Label: exact name within the issue's team (via `team.labels`) - Workflow state: exact name within the issue's team (existing `team.states` resolver, now reusable for `--state` on create) - Project status: exact name or type (via `projectStatuses`) ### Docs, tests, evals - SKILL.md command map, state-change gate, and error/recovery sections; README; `domain-and-workflows.md` (project semantics + 255-char limit), `graphql-contract.md` (resolution queries), `integration-boundaries.md` (intentional exclusions list), `sources.md` (2026-08-05 schema re-verification note). - 15 new offline tests (45 total) covering resolution, gates, dry-run intent, payload shapes, and field guards. - Added a sixth eval case (`safe-project-and-issue-mutations`). ## Validation - `python3 -m unittest linear/tests/test_linear.py` — 45/45 pass - `python3 scripts/validate-evals.py`, `ruby scripts/validate-skills.rb`, `python3 scripts/check-artifacts.py`, `python3 scripts/eval-coverage.py --modified-from origin/main`, skill-quality validator, marketplace/codex/llms freshness, jscpd — all green locally Closes #287 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
5d101007ef
commit
b8a5092c26
+4
-3
@@ -3,8 +3,9 @@
|
||||
## Why Install This Skill
|
||||
|
||||
Give an agent a small, predictable way to work with Linear without running an MCP server or
|
||||
installing a package. It can find teams, projects, cycles, and issues, inspect documents, and
|
||||
make carefully previewed issue changes through Linear's public API.
|
||||
installing a package. It can find teams, projects, cycles, workflow states, and issues, inspect
|
||||
documents, and make carefully previewed changes to issues, projects, and comments through Linear's
|
||||
public API.
|
||||
|
||||
The CLI stays intentionally narrow: it favors bounded reads, JSON output (compact with `--json`,
|
||||
indented without it), and dry-run previews over a large API mirror. That makes routine
|
||||
@@ -14,7 +15,7 @@ project-management work easier to audit.
|
||||
|
||||
| Path | Provides |
|
||||
|---|---|
|
||||
| `scripts/linear` | Dependency-free Python CLI for Linear GraphQL reads and issue mutations |
|
||||
| `scripts/linear` | Dependency-free Python CLI for Linear GraphQL reads and confirmed mutations (issues, projects, workflow states, comments) |
|
||||
| `SKILL.md` | Agent workflow, safety gate, command routing, and official API links |
|
||||
| `tests/test_linear.py` | Offline tests for parsing, safety gates, GraphQL contracts, and dry-run behavior |
|
||||
| `references/` | Linear workflow, GraphQL, integration-boundary, and source guidance |
|
||||
|
||||
+19
-7
@@ -37,11 +37,16 @@ the full command reference into a response.
|
||||
|---|---|
|
||||
| Confirm current identity | `scripts/linear whoami --json` |
|
||||
| Discover teams | `scripts/linear team list --limit 20 --json` |
|
||||
| List a team's workflow states | `scripts/linear state list --team ENG --json` |
|
||||
| Narrow a known issue set | `scripts/linear issue list --team ENG --state "In Progress" --json` |
|
||||
| Find an issue by words | `scripts/linear issue search "customer import" --json` |
|
||||
| Read one known issue | `scripts/linear issue get ENG-42 --json` |
|
||||
| Read an issue with its project, cycle, hierarchy, comments, and relations | `scripts/linear issue get ENG-42 --detail --json` |
|
||||
| Create an issue with project, parent, assignee, labels, state, or due date | `scripts/linear issue create --team ENG --title "Fix login" --project "Platform" --parent ENG-1 --assignee "Ada Lovelace" --label bug --state "In Progress" --due 2026-08-31` |
|
||||
| Update an issue's assignee, labels, due date, or project | `scripts/linear issue update ENG-42 --assignee "Ada Lovelace" --label bug --remove-label p2 --due 2026-08-31 --project "Platform"` |
|
||||
| Archive or unarchive an issue | `scripts/linear issue archive ENG-42` or `scripts/linear issue unarchive ENG-42` |
|
||||
| List or read projects | `scripts/linear project list --team ENG --json` or `scripts/linear project get "Roadmap" --json` |
|
||||
| Update a project | `scripts/linear project update "Roadmap" --description "Q3 plan" --status started` |
|
||||
| List or read cycles | `scripts/linear cycle list --team ENG --json` or `scripts/linear cycle get UUID --json` |
|
||||
| Find documents by words | `scripts/linear document search roadmap --json` |
|
||||
| Read a document by UUID, slug, or URL | `scripts/linear document get REF --json` |
|
||||
@@ -54,6 +59,7 @@ the full command reference into a response.
|
||||
| You know an issue identifier or UUID | `issue get` |
|
||||
| You have words but not an identifier | `issue search` or `document search` |
|
||||
| You need a bounded set with filters | `issue list`, `document list`, `project list`, or `cycle list` |
|
||||
| You need a team's workflow states | `state list --team ENG` |
|
||||
| The task needs a documented operation outside this focused CLI | `raw` with an explicit GraphQL query |
|
||||
|
||||
`raw` is an escape hatch, not a replacement for normal commands. Keep its query narrow and use
|
||||
@@ -63,26 +69,32 @@ the official GraphQL documentation to confirm field names and permissions.
|
||||
|
||||
Confirm the target, scope, and rollback path before acting. Read-only discovery may proceed without confirmation.
|
||||
|
||||
For `issue create`, `issue update`, `issue move`, `issue comment`, and raw GraphQL mutations:
|
||||
For `issue create`, `issue update`, `issue move`, `issue comment`, `issue archive`, `issue unarchive`,
|
||||
`project update`, and raw GraphQL mutations:
|
||||
|
||||
1. Identify the issue/team/state using a read command.
|
||||
1. Identify the issue/team/project/state using a read command.
|
||||
2. State the exact intended change and recovery path to the user.
|
||||
3. Run the same command with `--dry-run --json`; this has no credentials or network requirement.
|
||||
4. After confirmation, rerun it with `--confirm --json`.
|
||||
5. Report the returned identifier and outcome without exposing credentials.
|
||||
|
||||
The `--team` filters for issue, project, and cycle lists require an exact team key. `issue create`
|
||||
resolves a team key or exact name before creation, resolves issue identifiers before comments or
|
||||
updates, and resolves a destination workflow state only within that issue's team.
|
||||
It does not guess IDs or workflow states. Load `references/domain-and-workflows.md` for safe
|
||||
mutation recipes and Linear workflow semantics.
|
||||
resolves a team key or exact name before creation, resolves issue identifiers before comments,
|
||||
updates, or archiving, resolves a project by UUID or exact name, an assignee by exact name or
|
||||
email, labels within the issue's team, and a destination workflow state only within that issue's
|
||||
team. `project update` resolves the project by UUID or exact name and the status by name or type.
|
||||
It does not guess IDs, workflow states, labels, or project statuses. Load
|
||||
`references/domain-and-workflows.md` for safe mutation recipes and Linear workflow semantics.
|
||||
|
||||
## Errors And Recovery
|
||||
|
||||
- Missing credentials: export one supported environment variable only for the command session, or use `--dry-run` to inspect the request.
|
||||
- GraphQL error: the CLI writes Linear's first useful error message to stderr and exits nonzero, including when the HTTP status is 200. Check permissions, exact identifiers, and documented field availability.
|
||||
- Team ambiguity: use `team list` to choose an exact key/name; do not retry by guessing an ID.
|
||||
- State lookup failure: the error lists the available states for the issue's team. Use that exact name, or use documented `raw` GraphQL as the explicit escape hatch when the focused CLI cannot express the operation.
|
||||
- State lookup failure: list the team's states first with `state list --team`, then use that exact name. The error also lists the available states for the issue's team.
|
||||
- Label lookup failure: the error lists the available labels in the issue's team. Use that exact name.
|
||||
- Project status lookup failure: the error lists the available statuses. Use an exact name or type (for example `planned`, `started`, `paused`, `completed`, `canceled`).
|
||||
- Project description rejection: Linear's `projectUpdate` rejects descriptions longer than 255 characters. Keep project descriptions at 255 characters or fewer.
|
||||
- Limit failure: choose a value from 1 through 100. The CLI deliberately does not paginate automatically.
|
||||
- Rate limit or transport failure: wait and retry the same bounded read. Follow Linear's [rate limiting guidance](https://linear.app/developers/rate-limiting) rather than adding a retry loop.
|
||||
|
||||
|
||||
@@ -61,6 +61,18 @@
|
||||
"The response explains Linear's synchronous mutation behavior and its limits under network uncertainty",
|
||||
"Retry logic can deduplicate via identifiers or request tracking"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "safe-project-and-issue-mutations",
|
||||
"prompt": "I need to rename a Linear project, move it to the 'started' status, and create an issue inside it assigned to Ada, tagged with the bug label, with a due date — without accidentally applying anything before I review it. How do I do this safely with the linear CLI, and what should I not assume about the API?",
|
||||
"expected_output": "A safe mutation flow built on the linear skill's verbs: first read to confirm targets (team list, project get, state list, and issue search to check for an existing issue), then preview every write with --dry-run --json (project update with --name/--status, and issue create with --project/--assignee/--label/--due), then rerun each with --confirm --json after reviewing the preview. The response explains that friendly names are resolved only during a live run, that project statuses, workflow states, and labels are workspace- or team-defined and must be resolved by exact name rather than assumed, that due dates are ISO YYYY-MM-DD, and that Linear's projectUpdate rejects project descriptions longer than 255 characters. It also states the recovery path: issue update and issue move can revert an issue change, issue unarchive reverses an archive, and destructive deletion is deliberately not exposed as a CLI verb.",
|
||||
"assertions": [
|
||||
"The flow reads before writing and checks for an existing issue first",
|
||||
"Every mutation is previewed with --dry-run and applied only with --confirm",
|
||||
"Project status, workflow state, and label resolution by exact name is explained, not assumed",
|
||||
"The 255-character project description limit is mentioned",
|
||||
"The recovery path (update/move/unarchive) and the absence of a delete verb are stated"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,22 @@ Triage when that feature is enabled. Priorities are numeric in the API: 0 none,
|
||||
|
||||
Issue IDs can be UUIDs or shorthand identifiers such as `ENG-42`. Obtain object UUIDs in Linear
|
||||
with the command menu's “Copy model UUID” action. A parent issue groups child issues; use children
|
||||
only when their work is independently actionable and trackable.
|
||||
only when their work is independently actionable and trackable. Assignees resolve by exact name or
|
||||
email within the workspace; labels resolve by exact name within the issue's team; due dates are ISO
|
||||
dates (`YYYY-MM-DD`). `issue create` accepts `--project`, `--parent`, `--assignee`, `--label`,
|
||||
`--state`, and `--due`; `issue update` accepts `--assignee`, `--label` (add), `--remove-label`,
|
||||
`--due`, and `--project`. Archived issues are excluded from default reads; `issue archive` and
|
||||
`issue unarchive` move an issue across that boundary and use the same confirmation gate as other
|
||||
mutations.
|
||||
|
||||
## Project Semantics
|
||||
|
||||
Projects carry a status, a priority, start and target dates, a lead, and members. `project update`
|
||||
changes the name, description, status, dates, or priority and requires the same dry-run/confirm
|
||||
gate as issue mutations. Linear's `projectUpdate` rejects project descriptions longer than 255
|
||||
characters with a generic argument-validation error; keep project descriptions at 255 characters
|
||||
or fewer. Project statuses are workspace-defined, so resolve the destination status by its exact
|
||||
name or type inside the workspace rather than assuming a universal set.
|
||||
|
||||
## Choosing A Work Item
|
||||
|
||||
@@ -22,8 +37,9 @@ then inspect the result before changing it. Use the issue identifier in follow-u
|
||||
is shorter and is accepted by the public API.
|
||||
|
||||
Use a project when the user asks about a larger initiative or its status. Use a cycle when the user
|
||||
asks about a team's current or planned timebox. Neither changes the mutation boundary of this CLI:
|
||||
all writes remain issue-scoped.
|
||||
asks about a team's current or planned timebox. The mutation boundary stays bounded: issues,
|
||||
projects, workflow states, and comments are first-class verbs; cycles, teams, and documents remain
|
||||
read-only, and destructive deletion stays behind `raw`.
|
||||
|
||||
Descriptions, comments, and documents support Markdown. Plain Linear URLs to users, issues,
|
||||
projects, and other resources become mentions in the Linear UI. Collapsible Markdown sections use
|
||||
@@ -36,8 +52,8 @@ projects, and other resources become mentions in the Linear UI. Collapsible Mark
|
||||
3. State the exact change and recovery path to the user.
|
||||
4. Run the mutation with `--dry-run --json`; friendly references are only resolved during a live run.
|
||||
5. Run the same command with `--confirm --json` only after confirmation.
|
||||
6. Report the returned identifier and outcome. If a change is wrong, use `issue update` or `issue move`
|
||||
to restore the prior value rather than guessing.
|
||||
6. Report the returned identifier and outcome. If a change is wrong, use `issue update`, `issue move`,
|
||||
`project update`, or `issue unarchive` to restore the prior value rather than guessing.
|
||||
|
||||
Use an issue comment for a dated, issue-specific update. Use a document when the information must
|
||||
remain useful beyond one issue. Do not create duplicates for work that an existing issue already
|
||||
|
||||
@@ -4,10 +4,17 @@ Endpoint: `https://api.linear.app/graphql`. Send JSON GraphQL requests with `Con
|
||||
application/json`. A personal API key is the value of the `Authorization` header. An OAuth access
|
||||
token is sent in the `Authorization` header with the `Bearer ` prefix.
|
||||
|
||||
This CLI supports bounded reads for teams, issues, documents, projects, and cycles, plus
|
||||
issue-scoped create, update, move, and comment mutations. Use `raw` for a documented operation
|
||||
outside that surface. Issue reads accept a UUID or shorthand identifier. Document reads accept a
|
||||
UUID, slug ID, or Linear document URL; slug lookup uses the `documents` filter.
|
||||
This CLI supports bounded reads for teams, issues, documents, projects, cycles, and workflow
|
||||
states, plus issue create, update, move, archive, unarchive, and comment mutations, and project
|
||||
update. Use `raw` for a documented operation outside that surface. Issue reads accept a UUID or
|
||||
shorthand identifier. Document reads accept a UUID, slug ID, or Linear document URL; slug lookup
|
||||
uses the `documents` filter.
|
||||
|
||||
Friendly-name resolution uses bounded queries: teams via the `teams` connection, users by exact
|
||||
name/email via the `users` connection, labels within a team via `team.labels`, project statuses via
|
||||
`projectStatuses`, and workflow states within a team via `team.states`. Each resolver requires
|
||||
exactly one match and fails with the available names on ambiguity. Resolvers query at most 100
|
||||
items; a workspace or team with more matches than the first page cannot be resolved by name.
|
||||
|
||||
For personal scripts, a personal API key is the simplest authentication method. OAuth is intended
|
||||
for applications acting on behalf of users; access tokens are sent with the Bearer prefix. This CLI
|
||||
|
||||
@@ -36,3 +36,12 @@ belongs in the focused surface only when it has a recurring agent workflow, a cl
|
||||
and a bounded contract. Promotion is complete only when CLI help and routing, SKILL/README command
|
||||
maps, official source provenance, offline request/failure tests, schema re-verification, and the
|
||||
repository regression gates all agree. Until then, keep the verified operation behind narrow `raw`.
|
||||
|
||||
## Intentional Exclusions
|
||||
|
||||
These documented operations stay behind `raw` on purpose and are not promoted to first-class
|
||||
verbs: issue, project, cycle, and document deletion (irreversible by default); attachment uploads
|
||||
(multipart, outside the JSON endpoint); bulk issue create/update (`issueBatchCreate`/`issueBatchUpdate`);
|
||||
team, user, workflow-state, and organization administration; and integrations, webhooks, initiatives,
|
||||
customers, and notifications. If a workflow needs one of these repeatedly, review the promotion
|
||||
criteria above before adding a verb.
|
||||
|
||||
@@ -29,6 +29,12 @@ for field availability and retain the documentation URL plus access date as prov
|
||||
official page contains internally conflicting limit values, avoid encoding either value in CLI
|
||||
behavior and inspect response headers during credentialed operation.
|
||||
|
||||
Re-verified 2026-08-05 against the live public schema via anonymous introspection for the
|
||||
mutation surface added in the project/state change: `projectUpdate` (returns `ProjectPayload.project`),
|
||||
`issueArchive`/`issueUnarchive` (return `IssueArchivePayload.entity`), `team.labels`,
|
||||
`projectStatuses`, `users`, and the `ProjectFilter.accessibleTeams` / `CycleFilter.team` filters.
|
||||
Record the exact selection and update the offline CLI contract tests on any future change.
|
||||
|
||||
The CLI's offline tests verify parser, safety-gate, and error-handling contracts only. They do not
|
||||
replace a credentialed workspace smoke test for permissions, workspace-specific names, or returned
|
||||
resource data.
|
||||
|
||||
+353
-18
@@ -16,6 +16,7 @@ MAX_LIMIT = 100
|
||||
UUID_RE = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I
|
||||
)
|
||||
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
ISSUE_FIELDS = "id identifier title description priority url createdAt updatedAt team { id name key } state { id name type } assignee { id name } labels { nodes { id name } }"
|
||||
ISSUE_DETAIL_FIELDS = (
|
||||
@@ -78,6 +79,21 @@ def parse_document_ref(ref):
|
||||
return "slugId", ref
|
||||
|
||||
|
||||
def parse_date(value, label):
|
||||
if not DATE_RE.match(value):
|
||||
fail("%s must be an ISO date in YYYY-MM-DD form" % label)
|
||||
return value
|
||||
|
||||
|
||||
def validate_project_description(value):
|
||||
if len(value) > 255:
|
||||
fail(
|
||||
"project description must be 255 characters or fewer; "
|
||||
"Linear's projectUpdate API rejects longer descriptions"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def is_mutation(query):
|
||||
index = 0
|
||||
braces = parens = brackets = 0
|
||||
@@ -251,9 +267,9 @@ def resolve_issue(client, ref):
|
||||
return require_found(data.get("issue"), "issue", ref)
|
||||
|
||||
|
||||
def resolve_state(client, issue, state_name):
|
||||
def resolve_state(client, team, state_name):
|
||||
query = "query TeamStates($id: String!, $first: Int!) { team(id: $id) { states(first: $first) { nodes { id name type } } } }"
|
||||
data = client.run(query, {"id": issue["team"]["id"], "first": MAX_LIMIT})
|
||||
data = client.run(query, {"id": team["id"], "first": MAX_LIMIT})
|
||||
states = (data.get("team") or {}).get("states") or {}
|
||||
states = states.get("nodes", states) if isinstance(states, dict) else states
|
||||
matches = [state for state in states if state["name"].lower() == state_name.lower()]
|
||||
@@ -261,7 +277,7 @@ def resolve_state(client, issue, state_name):
|
||||
available = ", ".join(state["name"] for state in states) or "none"
|
||||
fail(
|
||||
"state %r did not resolve in team %s; available states: %s"
|
||||
% (state_name, issue["team"]["key"], available)
|
||||
% (state_name, team["key"], available)
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
@@ -352,6 +368,59 @@ def resolve_project(client, ref):
|
||||
return matches[0]
|
||||
|
||||
|
||||
def resolve_user(client, ref):
|
||||
query = "query Users($first: Int!) { users(first: $first) { nodes { id name displayName email } } }"
|
||||
data = client.run(query, {"first": MAX_LIMIT})
|
||||
matches = [
|
||||
user
|
||||
for user in nodes(data, "users")
|
||||
if user["id"] == ref
|
||||
or user["name"].lower() == ref.lower()
|
||||
or (user.get("displayName") or "").lower() == ref.lower()
|
||||
or (user.get("email") or "").lower() == ref.lower()
|
||||
]
|
||||
if len(matches) != 1:
|
||||
fail(
|
||||
"user %r did not resolve to exactly one user; use an exact name or email"
|
||||
% ref
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def resolve_label(client, team, name):
|
||||
query = "query TeamLabels($id: String!, $first: Int!) { team(id: $id) { labels(first: $first) { nodes { id name } } } }"
|
||||
data = client.run(query, {"id": team["id"], "first": MAX_LIMIT})
|
||||
labels = (data.get("team") or {}).get("labels") or {}
|
||||
labels = labels.get("nodes", labels) if isinstance(labels, dict) else labels
|
||||
matches = [label for label in labels if label["name"].lower() == name.lower()]
|
||||
if len(matches) != 1:
|
||||
available = ", ".join(label["name"] for label in labels) or "none"
|
||||
fail(
|
||||
"label %r did not resolve in team %s; available labels: %s"
|
||||
% (name, team["key"], available)
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def resolve_project_status(client, name):
|
||||
query = "query ProjectStatuses($first: Int!) { projectStatuses(first: $first) { nodes { id name type } } }"
|
||||
data = client.run(query, {"first": MAX_LIMIT})
|
||||
statuses = nodes(data, "projectStatuses")
|
||||
matches = [
|
||||
status
|
||||
for status in statuses
|
||||
if status["name"].lower() == name.lower()
|
||||
or status["type"].lower() == name.lower()
|
||||
]
|
||||
if len(matches) != 1:
|
||||
available = ", ".join(status["name"] for status in statuses) or "none"
|
||||
fail(
|
||||
"project status %r did not resolve; available statuses: %s"
|
||||
% (name, available)
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
class LinearArgumentParser(argparse.ArgumentParser):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("epilog", GLOBAL_OPTIONS_HELP)
|
||||
@@ -439,6 +508,25 @@ def build_parser():
|
||||
p.add_argument("--title", required=True)
|
||||
p.add_argument("--description")
|
||||
p.add_argument("--priority", choices=("none", "urgent", "high", "medium", "low"))
|
||||
p.add_argument(
|
||||
"--project", metavar="PROJECT", help="Project UUID or exact name to attach."
|
||||
)
|
||||
p.add_argument(
|
||||
"--parent", metavar="ISSUE", help="Parent issue identifier or UUID for a sub-issue."
|
||||
)
|
||||
p.add_argument(
|
||||
"--assignee", metavar="USER", help="Assignee exact name or email."
|
||||
)
|
||||
p.add_argument(
|
||||
"--label",
|
||||
action="append",
|
||||
metavar="LABEL",
|
||||
help="Label name in the issue's team (repeatable).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--state", metavar="STATE", help="Destination workflow state name in the team."
|
||||
)
|
||||
p.add_argument("--due", metavar="YYYY-MM-DD", help="Due date (ISO).")
|
||||
p.add_argument("--confirm", action="store_true")
|
||||
p = actions.add_parser(
|
||||
"update",
|
||||
@@ -449,6 +537,26 @@ def build_parser():
|
||||
p.add_argument("--title")
|
||||
p.add_argument("--description")
|
||||
p.add_argument("--priority", choices=("none", "urgent", "high", "medium", "low"))
|
||||
p.add_argument(
|
||||
"--assignee", metavar="USER", help="Assignee exact name or email."
|
||||
)
|
||||
p.add_argument(
|
||||
"--label",
|
||||
action="append",
|
||||
metavar="LABEL",
|
||||
help="Label name in the issue's team to add (repeatable).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--remove-label",
|
||||
action="append",
|
||||
dest="remove_label",
|
||||
metavar="LABEL",
|
||||
help="Label name in the issue's team to remove (repeatable).",
|
||||
)
|
||||
p.add_argument("--due", metavar="YYYY-MM-DD", help="Due date (ISO).")
|
||||
p.add_argument(
|
||||
"--project", metavar="PROJECT", help="Project UUID or exact name to attach."
|
||||
)
|
||||
p.add_argument("--confirm", action="store_true")
|
||||
p = actions.add_parser(
|
||||
"move",
|
||||
@@ -466,6 +574,20 @@ def build_parser():
|
||||
p.add_argument("issue")
|
||||
p.add_argument("--body", required=True)
|
||||
p.add_argument("--confirm", action="store_true")
|
||||
p = actions.add_parser(
|
||||
"archive",
|
||||
help="Archive an issue.",
|
||||
description="Example: linear issue archive ENG-42",
|
||||
)
|
||||
p.add_argument("issue")
|
||||
p.add_argument("--confirm", action="store_true")
|
||||
p = actions.add_parser(
|
||||
"unarchive",
|
||||
help="Unarchive an issue.",
|
||||
description="Example: linear issue unarchive ENG-42",
|
||||
)
|
||||
p.add_argument("issue")
|
||||
p.add_argument("--confirm", action="store_true")
|
||||
document = root.add_parser("document", help="Read Linear documents.")
|
||||
actions = document.add_subparsers(dest="action", required=True)
|
||||
actions.add_parser(
|
||||
@@ -499,6 +621,24 @@ def build_parser():
|
||||
description='Example: linear project get "Roadmap"',
|
||||
)
|
||||
p.add_argument("project")
|
||||
p = actions.add_parser(
|
||||
"update",
|
||||
help="Update a project.",
|
||||
description='Example: linear project update "Roadmap" --description "Q3 plan" --status started',
|
||||
)
|
||||
p.add_argument("project")
|
||||
p.add_argument("--name")
|
||||
p.add_argument("--description")
|
||||
p.add_argument(
|
||||
"--status",
|
||||
help="Project status name or type (planned, started, paused, completed, canceled).",
|
||||
)
|
||||
p.add_argument("--start-date", dest="start_date", help="Start date (ISO YYYY-MM-DD).")
|
||||
p.add_argument(
|
||||
"--target-date", dest="target_date", help="Target date (ISO YYYY-MM-DD)."
|
||||
)
|
||||
p.add_argument("--priority", choices=("none", "urgent", "high", "medium", "low"))
|
||||
p.add_argument("--confirm", action="store_true")
|
||||
cycle = root.add_parser("cycle", help="Read Linear cycles.")
|
||||
actions = cycle.add_subparsers(dest="action", required=True)
|
||||
p = actions.add_parser(
|
||||
@@ -513,6 +653,16 @@ def build_parser():
|
||||
description="Example: linear cycle get 123e4567-e89b-12d3-a456-426614174000",
|
||||
)
|
||||
p.add_argument("cycle")
|
||||
state = root.add_parser("state", help="Read workflow states.")
|
||||
actions = state.add_subparsers(dest="action", required=True)
|
||||
p = actions.add_parser(
|
||||
"list",
|
||||
help="List workflow states for a team.",
|
||||
description="Example: linear state list --team ENG --json",
|
||||
)
|
||||
p.add_argument(
|
||||
"--team", required=True, metavar="TEAM_KEY", help="Team key or exact name."
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -544,7 +694,17 @@ def main(argv=None):
|
||||
args.noun == "issue"
|
||||
and args.action == "update"
|
||||
and all(
|
||||
value is None for value in (args.title, args.description, args.priority)
|
||||
value is None
|
||||
for value in (
|
||||
args.title,
|
||||
args.description,
|
||||
args.priority,
|
||||
args.assignee,
|
||||
args.label,
|
||||
args.remove_label,
|
||||
args.due,
|
||||
args.project,
|
||||
)
|
||||
)
|
||||
):
|
||||
fail("issue update requires at least one field to change")
|
||||
@@ -627,22 +787,47 @@ def main(argv=None):
|
||||
require_confirmation(args)
|
||||
if args.action == "create":
|
||||
if args.dry_run:
|
||||
preview(
|
||||
args,
|
||||
[
|
||||
{"operation": "resolve team", "reference": args.team},
|
||||
{
|
||||
"operation": "issueCreate",
|
||||
"input": {
|
||||
"team": args.team,
|
||||
"title": args.title,
|
||||
"description": args.description,
|
||||
"priority": args.priority,
|
||||
},
|
||||
operations = [
|
||||
{"operation": "resolve team", "reference": args.team}
|
||||
]
|
||||
if args.project:
|
||||
operations.append(
|
||||
{"operation": "resolve project", "reference": args.project}
|
||||
)
|
||||
if args.parent:
|
||||
operations.append(
|
||||
{"operation": "resolve parent issue", "reference": args.parent}
|
||||
)
|
||||
if args.assignee:
|
||||
operations.append(
|
||||
{"operation": "resolve assignee", "reference": args.assignee}
|
||||
)
|
||||
if args.label:
|
||||
operations.append(
|
||||
{"operation": "resolve labels", "names": args.label}
|
||||
)
|
||||
if args.state:
|
||||
operations.append(
|
||||
{"operation": "resolve state in team", "name": args.state}
|
||||
)
|
||||
operations.append(
|
||||
{
|
||||
"operation": "issueCreate",
|
||||
"input": {
|
||||
"team": args.team,
|
||||
"title": args.title,
|
||||
"description": args.description,
|
||||
"priority": args.priority,
|
||||
"project": args.project,
|
||||
"parent": args.parent,
|
||||
"assignee": args.assignee,
|
||||
"labels": args.label,
|
||||
"state": args.state,
|
||||
"due": args.due,
|
||||
},
|
||||
],
|
||||
mutation=True,
|
||||
}
|
||||
)
|
||||
preview(args, operations, mutation=True)
|
||||
return
|
||||
team = resolve_team(client, args.team)
|
||||
inp = {
|
||||
@@ -657,6 +842,20 @@ def main(argv=None):
|
||||
"low": 4,
|
||||
}.get(args.priority),
|
||||
}
|
||||
if args.project:
|
||||
inp["projectId"] = resolve_project(client, args.project)["id"]
|
||||
if args.parent:
|
||||
inp["parentId"] = resolve_issue(client, args.parent)["id"]
|
||||
if args.assignee:
|
||||
inp["assigneeId"] = resolve_user(client, args.assignee)["id"]
|
||||
if args.label:
|
||||
inp["labelIds"] = [
|
||||
resolve_label(client, team, name)["id"] for name in args.label
|
||||
]
|
||||
if args.state:
|
||||
inp["stateId"] = resolve_state(client, team, args.state)["id"]
|
||||
if args.due:
|
||||
inp["dueDate"] = parse_date(args.due, "--due")
|
||||
emit(
|
||||
client.run(
|
||||
"mutation IssueCreate($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { %s } } }"
|
||||
@@ -672,6 +871,28 @@ def main(argv=None):
|
||||
args,
|
||||
)
|
||||
return
|
||||
if args.action in ("archive", "unarchive"):
|
||||
mutation = "issueArchive" if args.action == "archive" else "issueUnarchive"
|
||||
if args.dry_run:
|
||||
preview(
|
||||
args,
|
||||
[
|
||||
{"operation": "resolve issue", "reference": args.issue},
|
||||
{"operation": mutation, "issue": args.issue},
|
||||
],
|
||||
mutation=True,
|
||||
)
|
||||
return
|
||||
issue = resolve_issue(client, args.issue)
|
||||
emit(
|
||||
client.run(
|
||||
"mutation IssueArchive($id: String!) { %s(id: $id) { success entity { %s } } }"
|
||||
% (mutation, ISSUE_FIELDS),
|
||||
{"id": issue["id"]},
|
||||
),
|
||||
args,
|
||||
)
|
||||
return
|
||||
if args.dry_run:
|
||||
operations = [{"operation": "resolve issue", "reference": args.issue}]
|
||||
if args.action == "move":
|
||||
@@ -693,6 +914,11 @@ def main(argv=None):
|
||||
"title": args.title,
|
||||
"description": args.description,
|
||||
"priority": args.priority,
|
||||
"assignee": args.assignee,
|
||||
"add_labels": args.label,
|
||||
"remove_labels": args.remove_label,
|
||||
"due": args.due,
|
||||
"project": args.project,
|
||||
}.items()
|
||||
if value is not None
|
||||
},
|
||||
@@ -721,6 +947,21 @@ def main(argv=None):
|
||||
"low": 4,
|
||||
}.get(args.priority),
|
||||
}
|
||||
team = issue["team"]
|
||||
if args.assignee:
|
||||
inp["assigneeId"] = resolve_user(client, args.assignee)["id"]
|
||||
if args.label:
|
||||
inp["addedLabelIds"] = [
|
||||
resolve_label(client, team, name)["id"] for name in args.label
|
||||
]
|
||||
if args.remove_label:
|
||||
inp["removedLabelIds"] = [
|
||||
resolve_label(client, team, name)["id"] for name in args.remove_label
|
||||
]
|
||||
if args.due:
|
||||
inp["dueDate"] = parse_date(args.due, "--due")
|
||||
if args.project:
|
||||
inp["projectId"] = resolve_project(client, args.project)["id"]
|
||||
inp = {key: value for key, value in inp.items() if value is not None}
|
||||
if not inp:
|
||||
fail("issue update requires at least one field to change")
|
||||
@@ -789,6 +1030,81 @@ def main(argv=None):
|
||||
if args.action == "list":
|
||||
project_list(client, args)
|
||||
return
|
||||
if args.action == "update":
|
||||
if all(
|
||||
value is None
|
||||
for value in (
|
||||
args.name,
|
||||
args.description,
|
||||
args.status,
|
||||
args.start_date,
|
||||
args.target_date,
|
||||
args.priority,
|
||||
)
|
||||
):
|
||||
fail("project update requires at least one field to change")
|
||||
if args.description:
|
||||
validate_project_description(args.description)
|
||||
require_confirmation(args)
|
||||
if args.dry_run:
|
||||
operations = [
|
||||
{"operation": "resolve project", "reference": args.project}
|
||||
]
|
||||
if args.status:
|
||||
operations.append(
|
||||
{
|
||||
"operation": "resolve project status",
|
||||
"reference": args.status,
|
||||
}
|
||||
)
|
||||
operations.append(
|
||||
{
|
||||
"operation": "projectUpdate",
|
||||
"project": args.project,
|
||||
"input": {
|
||||
key: value
|
||||
for key, value in {
|
||||
"name": args.name,
|
||||
"description": args.description,
|
||||
"status": args.status,
|
||||
"start_date": args.start_date,
|
||||
"target_date": args.target_date,
|
||||
"priority": args.priority,
|
||||
}.items()
|
||||
if value is not None
|
||||
},
|
||||
}
|
||||
)
|
||||
preview(args, operations, mutation=True)
|
||||
return
|
||||
project = resolve_project(client, args.project)
|
||||
inp = {
|
||||
"name": args.name,
|
||||
"description": args.description,
|
||||
"priority": {
|
||||
"none": 0,
|
||||
"urgent": 1,
|
||||
"high": 2,
|
||||
"medium": 3,
|
||||
"low": 4,
|
||||
}.get(args.priority),
|
||||
}
|
||||
if args.status:
|
||||
inp["statusId"] = resolve_project_status(client, args.status)["id"]
|
||||
if args.start_date:
|
||||
inp["startDate"] = parse_date(args.start_date, "--start-date")
|
||||
if args.target_date:
|
||||
inp["targetDate"] = parse_date(args.target_date, "--target-date")
|
||||
inp = {key: value for key, value in inp.items() if value is not None}
|
||||
emit(
|
||||
client.run(
|
||||
"mutation ProjectUpdate($id: String!, $input: ProjectUpdateInput!) { projectUpdate(id: $id, input: $input) { success project { %s } } }"
|
||||
% PROJECT_FIELDS,
|
||||
{"id": project["id"], "input": inp},
|
||||
),
|
||||
args,
|
||||
)
|
||||
return
|
||||
emit(resolve_project(client, args.project), args)
|
||||
return
|
||||
if args.noun == "cycle":
|
||||
@@ -817,6 +1133,25 @@ def main(argv=None):
|
||||
args,
|
||||
)
|
||||
return
|
||||
if args.noun == "state":
|
||||
if args.dry_run:
|
||||
preview(
|
||||
args,
|
||||
[
|
||||
{"operation": "resolve team", "reference": args.team},
|
||||
{"operation": "list team states", "team": args.team},
|
||||
],
|
||||
)
|
||||
return
|
||||
team = resolve_team(client, args.team)
|
||||
emit(
|
||||
client.run(
|
||||
"query TeamStates($id: String!, $first: Int!) { team(id: $id) { states(first: $first) { nodes { id name type description } } } }",
|
||||
{"id": team["id"], "first": args.limit},
|
||||
),
|
||||
args,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+515
-7
@@ -60,7 +60,7 @@ class LinearCliTests(unittest.TestCase):
|
||||
state_client = StateClient()
|
||||
self.assertEqual(
|
||||
cli.resolve_state(
|
||||
state_client, {"team": {"id": "team-id", "key": "ENG"}}, "Done"
|
||||
state_client, {"id": "team-id", "key": "ENG"}, "Done"
|
||||
),
|
||||
{"id": "state-id", "name": "Done", "type": "completed"},
|
||||
)
|
||||
@@ -443,14 +443,14 @@ class LinearCliTests(unittest.TestCase):
|
||||
"update",
|
||||
"move",
|
||||
"comment",
|
||||
"archive",
|
||||
"unarchive",
|
||||
)
|
||||
),
|
||||
*(["document", action] for action in ("list", "search", "get")),
|
||||
*(
|
||||
[noun, action]
|
||||
for noun in ("project", "cycle")
|
||||
for action in ("list", "get")
|
||||
),
|
||||
*(["project", action] for action in ("list", "get", "update")),
|
||||
*(["cycle", action] for action in ("list", "get")),
|
||||
["state", "list"],
|
||||
]
|
||||
for path in paths:
|
||||
with self.subTest(path=path):
|
||||
@@ -630,7 +630,7 @@ class LinearCliTests(unittest.TestCase):
|
||||
with redirect_stderr(stderr):
|
||||
with self.assertRaises(SystemExit):
|
||||
cli.resolve_state(
|
||||
Client(), {"team": {"id": "team-id", "key": "ENG"}}, "Missing"
|
||||
Client(), {"id": "team-id", "key": "ENG"}, "Missing"
|
||||
)
|
||||
self.assertIn("available states: Todo, Done", stderr.getvalue())
|
||||
|
||||
@@ -655,6 +655,514 @@ class LinearCliTests(unittest.TestCase):
|
||||
self.assertEqual(output, "")
|
||||
self.assertIn("GraphQL error: test", error)
|
||||
|
||||
def test_project_update_requires_a_field_before_network(self):
|
||||
original = cli.urlopen
|
||||
try:
|
||||
cli.urlopen = lambda *_args, **_kwargs: self.fail(
|
||||
"invalid project update made a network call"
|
||||
)
|
||||
code, output, error = self.run_cli(
|
||||
["project", "update", "Roadmap", "--json"]
|
||||
)
|
||||
finally:
|
||||
cli.urlopen = original
|
||||
self.assertEqual(code, 2)
|
||||
self.assertEqual(output, "")
|
||||
self.assertIn("at least one field", error)
|
||||
|
||||
def test_project_update_resolves_status_dates_and_priority(self):
|
||||
original = cli.Client.run
|
||||
calls = []
|
||||
|
||||
def run(_self, query, variables):
|
||||
calls.append((query, variables))
|
||||
if query.startswith("query ProjectsByName"):
|
||||
return {
|
||||
"projects": {"nodes": [{"id": "project-id", "name": "Roadmap"}]}
|
||||
}
|
||||
if query.startswith("query ProjectStatuses"):
|
||||
return {
|
||||
"projectStatuses": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "status-id",
|
||||
"name": "In Progress",
|
||||
"type": "started",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
return {
|
||||
"projectUpdate": {"success": True, "project": {"id": "project-id"}}
|
||||
}
|
||||
|
||||
try:
|
||||
cli.Client.run = run
|
||||
code, output, error = self.run_cli(
|
||||
[
|
||||
"project",
|
||||
"update",
|
||||
"Roadmap",
|
||||
"--name",
|
||||
"Q3 Roadmap",
|
||||
"--status",
|
||||
"started",
|
||||
"--start-date",
|
||||
"2026-08-10",
|
||||
"--target-date",
|
||||
"2026-11-30",
|
||||
"--priority",
|
||||
"high",
|
||||
"--confirm",
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
finally:
|
||||
cli.Client.run = original
|
||||
self.assertEqual(code, 0, error)
|
||||
query, variables = calls[-1]
|
||||
self.assertIn(
|
||||
"mutation ProjectUpdate($id: String!, $input: ProjectUpdateInput!)",
|
||||
query,
|
||||
)
|
||||
self.assertIn("projectUpdate(id: $id, input: $input)", query)
|
||||
self.assertIn("success project", query)
|
||||
self.assertEqual(variables["id"], "project-id")
|
||||
self.assertEqual(
|
||||
variables["input"],
|
||||
{
|
||||
"name": "Q3 Roadmap",
|
||||
"statusId": "status-id",
|
||||
"startDate": "2026-08-10",
|
||||
"targetDate": "2026-11-30",
|
||||
"priority": 2,
|
||||
},
|
||||
)
|
||||
|
||||
def test_project_description_length_guard(self):
|
||||
original = cli.urlopen
|
||||
try:
|
||||
cli.urlopen = lambda *_args, **_kwargs: self.fail(
|
||||
"oversized description made a network call"
|
||||
)
|
||||
code, output, error = self.run_cli(
|
||||
[
|
||||
"project",
|
||||
"update",
|
||||
"Roadmap",
|
||||
"--description",
|
||||
"x" * 256,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
finally:
|
||||
cli.urlopen = original
|
||||
self.assertEqual(code, 2)
|
||||
self.assertEqual(output, "")
|
||||
self.assertIn("255", error)
|
||||
|
||||
def test_project_update_dry_run_includes_intent(self):
|
||||
code, output, error = self.run_cli(
|
||||
[
|
||||
"project",
|
||||
"update",
|
||||
"Roadmap",
|
||||
"--description",
|
||||
"Q3 plan",
|
||||
"--status",
|
||||
"started",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
self.assertEqual(code, 0, error)
|
||||
operations = json.loads(output)["operations"]
|
||||
self.assertIn(
|
||||
{"operation": "resolve project status", "reference": "started"},
|
||||
operations,
|
||||
)
|
||||
self.assertEqual(operations[-1]["operation"], "projectUpdate")
|
||||
self.assertEqual(operations[-1]["input"]["description"], "Q3 plan")
|
||||
|
||||
def test_issue_archive_and_unarchive_require_confirm(self):
|
||||
for action in ("archive", "unarchive"):
|
||||
with self.subTest(action=action):
|
||||
code, _output, error = self.run_cli(
|
||||
["issue", action, "ENG-42", "--json"]
|
||||
)
|
||||
self.assertEqual(code, 6)
|
||||
self.assertIn("--confirm", error)
|
||||
|
||||
def test_issue_archive_uses_entity_payload(self):
|
||||
original = cli.Client.run
|
||||
queries = []
|
||||
|
||||
def run(_self, query, variables):
|
||||
queries.append(query)
|
||||
if query.startswith("query Issue"):
|
||||
return {
|
||||
"issue": {"id": "issue-id", "team": {"id": "team-id", "key": "ENG"}}
|
||||
}
|
||||
return {
|
||||
"issueArchive": {"success": True, "entity": {"id": "issue-id"}}
|
||||
}
|
||||
|
||||
try:
|
||||
cli.Client.run = run
|
||||
code, output, error = self.run_cli(
|
||||
["issue", "archive", "ENG-42", "--confirm", "--json"]
|
||||
)
|
||||
finally:
|
||||
cli.Client.run = original
|
||||
self.assertEqual(code, 0, error)
|
||||
self.assertIn("mutation IssueArchive($id: String!)", queries[-1])
|
||||
self.assertIn("issueArchive(id: $id) { success entity", queries[-1])
|
||||
self.assertEqual(
|
||||
json.loads(output),
|
||||
{"issueArchive": {"entity": {"id": "issue-id"}, "success": True}},
|
||||
)
|
||||
|
||||
def test_state_list_resolves_team_and_lists_states(self):
|
||||
original = cli.Client.run
|
||||
calls = []
|
||||
|
||||
def run(_self, query, variables):
|
||||
calls.append((query, variables))
|
||||
if query.startswith("query ResolveTeam"):
|
||||
return {
|
||||
"teams": {
|
||||
"nodes": [
|
||||
{"id": "team-id", "key": "ENG", "name": "Engineering"}
|
||||
]
|
||||
}
|
||||
}
|
||||
return {
|
||||
"team": {
|
||||
"states": {
|
||||
"nodes": [{"id": "s1", "name": "Todo", "type": "backlog"}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
cli.Client.run = run
|
||||
code, output, error = self.run_cli(
|
||||
["state", "list", "--team", "ENG", "--json"]
|
||||
)
|
||||
finally:
|
||||
cli.Client.run = original
|
||||
self.assertEqual(code, 0, error)
|
||||
self.assertIn(
|
||||
"query TeamStates($id: String!, $first: Int!)", calls[-1][0]
|
||||
)
|
||||
self.assertEqual(calls[-1][1], {"id": "team-id", "first": 10})
|
||||
self.assertEqual(
|
||||
json.loads(output),
|
||||
{"team": {"states": {"nodes": [{"id": "s1", "name": "Todo", "type": "backlog"}]}}},
|
||||
)
|
||||
|
||||
def test_state_list_dry_run_previews_without_network(self):
|
||||
original = cli.urlopen
|
||||
try:
|
||||
cli.urlopen = lambda *_args, **_kwargs: self.fail(
|
||||
"dry-run made a network call"
|
||||
)
|
||||
code, output, error = self.run_cli(
|
||||
["state", "list", "--team", "ENG", "--dry-run", "--json"]
|
||||
)
|
||||
finally:
|
||||
cli.urlopen = original
|
||||
self.assertEqual(code, 0, error)
|
||||
self.assertTrue(json.loads(output)["dry_run"])
|
||||
|
||||
def test_resolve_user_matches_name_and_email_and_rejects_ambiguity(self):
|
||||
class Client:
|
||||
def __init__(self, users):
|
||||
self.users = users
|
||||
|
||||
def run(self, _query, _variables):
|
||||
return {"users": {"nodes": self.users}}
|
||||
|
||||
users = [
|
||||
{
|
||||
"id": "u1",
|
||||
"name": "Ada Lovelace",
|
||||
"displayName": "Ada",
|
||||
"email": "ada@example.com",
|
||||
},
|
||||
{
|
||||
"id": "u2",
|
||||
"name": "Grace Hopper",
|
||||
"displayName": "Grace",
|
||||
"email": "grace@example.com",
|
||||
},
|
||||
]
|
||||
self.assertEqual(cli.resolve_user(Client(users), "ada@example.com")["id"], "u1")
|
||||
self.assertEqual(cli.resolve_user(Client(users), "Ada Lovelace")["id"], "u1")
|
||||
self.assertEqual(cli.resolve_user(Client(users), "grace")["id"], "u2")
|
||||
with redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit) as exc:
|
||||
cli.resolve_user(Client(users), "missing")
|
||||
self.assertEqual(exc.exception.code, 2)
|
||||
|
||||
def test_resolve_label_reports_available_names(self):
|
||||
class Client:
|
||||
def run(self, _query, _variables):
|
||||
return {
|
||||
"team": {
|
||||
"labels": {
|
||||
"nodes": [
|
||||
{"id": "l1", "name": "bug"},
|
||||
{"id": "l2", "name": "feature"},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
cli.resolve_label(Client(), {"id": "team-id", "key": "ENG"}, "Bug")["id"],
|
||||
"l1",
|
||||
)
|
||||
stderr = io.StringIO()
|
||||
with redirect_stderr(stderr):
|
||||
with self.assertRaises(SystemExit):
|
||||
cli.resolve_label(Client(), {"id": "team-id", "key": "ENG"}, "Missing")
|
||||
self.assertIn("available labels: bug, feature", stderr.getvalue())
|
||||
|
||||
def test_resolve_project_status_matches_name_or_type(self):
|
||||
class Client:
|
||||
def run(self, _query, _variables):
|
||||
return {
|
||||
"projectStatuses": {
|
||||
"nodes": [
|
||||
{"id": "ps1", "name": "Planned", "type": "planned"},
|
||||
{"id": "ps2", "name": "In Progress", "type": "started"},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
self.assertEqual(cli.resolve_project_status(Client(), "Planned")["id"], "ps1")
|
||||
self.assertEqual(cli.resolve_project_status(Client(), "started")["id"], "ps2")
|
||||
with redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit):
|
||||
cli.resolve_project_status(Client(), "nope")
|
||||
|
||||
def test_parse_date_rejects_bad_format(self):
|
||||
self.assertEqual(cli.parse_date("2026-08-31", "--due"), "2026-08-31")
|
||||
with redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit) as exc:
|
||||
cli.parse_date("08/31/2026", "--due")
|
||||
self.assertEqual(exc.exception.code, 2)
|
||||
|
||||
def test_issue_create_resolves_project_parent_assignee_labels_state_and_due(self):
|
||||
original = cli.Client.run
|
||||
calls = []
|
||||
|
||||
def run(_self, query, variables):
|
||||
calls.append((query, variables))
|
||||
if query.startswith("query ResolveTeam"):
|
||||
return {
|
||||
"teams": {
|
||||
"nodes": [
|
||||
{"id": "team-id", "key": "ENG", "name": "Engineering"}
|
||||
]
|
||||
}
|
||||
}
|
||||
if query.startswith("query ProjectsByName"):
|
||||
return {
|
||||
"projects": {"nodes": [{"id": "project-id", "name": "Platform"}]}
|
||||
}
|
||||
if query.startswith("query Issue("):
|
||||
return {"issue": {"id": "parent-id"}}
|
||||
if query.startswith("query Users"):
|
||||
return {
|
||||
"users": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "user-id",
|
||||
"name": "Ada Lovelace",
|
||||
"displayName": "Ada",
|
||||
"email": "ada@example.com",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
if query.startswith("query TeamLabels"):
|
||||
return {
|
||||
"team": {"labels": {"nodes": [{"id": "label-id", "name": "bug"}]}}
|
||||
}
|
||||
if query.startswith("query TeamStates"):
|
||||
return {
|
||||
"team": {
|
||||
"states": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "state-id",
|
||||
"name": "In Progress",
|
||||
"type": "started",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
return {"issueCreate": {"success": True, "issue": {"id": "issue-id"}}}
|
||||
|
||||
try:
|
||||
cli.Client.run = run
|
||||
code, output, error = self.run_cli(
|
||||
[
|
||||
"issue",
|
||||
"create",
|
||||
"--team",
|
||||
"ENG",
|
||||
"--title",
|
||||
"Ship it",
|
||||
"--project",
|
||||
"Platform",
|
||||
"--parent",
|
||||
"ENG-41",
|
||||
"--assignee",
|
||||
"Ada Lovelace",
|
||||
"--label",
|
||||
"bug",
|
||||
"--state",
|
||||
"In Progress",
|
||||
"--due",
|
||||
"2026-08-31",
|
||||
"--confirm",
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
finally:
|
||||
cli.Client.run = original
|
||||
self.assertEqual(code, 0, error)
|
||||
query, variables = calls[-1]
|
||||
self.assertIn("mutation IssueCreate($input: IssueCreateInput!)", query)
|
||||
self.assertEqual(
|
||||
variables["input"],
|
||||
{
|
||||
"teamId": "team-id",
|
||||
"title": "Ship it",
|
||||
"projectId": "project-id",
|
||||
"parentId": "parent-id",
|
||||
"assigneeId": "user-id",
|
||||
"labelIds": ["label-id"],
|
||||
"stateId": "state-id",
|
||||
"dueDate": "2026-08-31",
|
||||
},
|
||||
)
|
||||
|
||||
def test_issue_update_resolves_assignee_labels_due_and_project(self):
|
||||
original = cli.Client.run
|
||||
calls = []
|
||||
|
||||
def run(_self, query, variables):
|
||||
calls.append((query, variables))
|
||||
if query.startswith("query Issue"):
|
||||
return {
|
||||
"issue": {"id": "issue-id", "team": {"id": "team-id", "key": "ENG"}}
|
||||
}
|
||||
if query.startswith("query Users"):
|
||||
return {
|
||||
"users": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "user-id",
|
||||
"name": "Ada Lovelace",
|
||||
"displayName": "Ada",
|
||||
"email": "ada@example.com",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
if query.startswith("query TeamLabels"):
|
||||
return {
|
||||
"team": {
|
||||
"labels": {
|
||||
"nodes": [
|
||||
{"id": "label-id", "name": "bug"},
|
||||
{"id": "label2-id", "name": "priority"},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
if query.startswith("query ProjectsByName"):
|
||||
return {
|
||||
"projects": {"nodes": [{"id": "project-id", "name": "Platform"}]}
|
||||
}
|
||||
return {"issueUpdate": {"success": True, "issue": {"id": "issue-id"}}}
|
||||
|
||||
try:
|
||||
cli.Client.run = run
|
||||
code, output, error = self.run_cli(
|
||||
[
|
||||
"issue",
|
||||
"update",
|
||||
"ENG-42",
|
||||
"--assignee",
|
||||
"Ada Lovelace",
|
||||
"--label",
|
||||
"bug",
|
||||
"--remove-label",
|
||||
"priority",
|
||||
"--due",
|
||||
"2026-09-15",
|
||||
"--project",
|
||||
"Platform",
|
||||
"--confirm",
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
finally:
|
||||
cli.Client.run = original
|
||||
self.assertEqual(code, 0, error)
|
||||
query, variables = calls[-1]
|
||||
self.assertIn(
|
||||
"mutation IssueUpdate($id: String!, $input: IssueUpdateInput!)", query
|
||||
)
|
||||
self.assertEqual(variables["id"], "issue-id")
|
||||
self.assertEqual(
|
||||
variables["input"],
|
||||
{
|
||||
"assigneeId": "user-id",
|
||||
"addedLabelIds": ["label-id"],
|
||||
"removedLabelIds": ["label2-id"],
|
||||
"dueDate": "2026-09-15",
|
||||
"projectId": "project-id",
|
||||
},
|
||||
)
|
||||
|
||||
def test_issue_create_dry_run_lists_resolution_intent(self):
|
||||
code, output, error = self.run_cli(
|
||||
[
|
||||
"issue",
|
||||
"create",
|
||||
"--team",
|
||||
"ENG",
|
||||
"--title",
|
||||
"Ship it",
|
||||
"--project",
|
||||
"Platform",
|
||||
"--assignee",
|
||||
"Ada",
|
||||
"--label",
|
||||
"bug",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
self.assertEqual(code, 0, error)
|
||||
operations = json.loads(output)["operations"]
|
||||
self.assertIn(
|
||||
{"operation": "resolve project", "reference": "Platform"}, operations
|
||||
)
|
||||
self.assertIn(
|
||||
{"operation": "resolve assignee", "reference": "Ada"}, operations
|
||||
)
|
||||
self.assertEqual(operations[-1]["operation"], "issueCreate")
|
||||
self.assertEqual(operations[-1]["input"]["labels"], ["bug"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user