diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b49210..fdb0b9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **`linear create` refuses to create an unassigned issue.** An issue with no + assignee is nobody's job — it lands in the board's "No assignee" bucket and + nobody picks it up. `create` now fails closed with a message explaining why it + refused and how to fix it, instead of silently producing an unowned ticket. + The ordinary case is unchanged: with no `--assign`, the API key owner is still + the assignee. Three paths now error where they previously did not: + + - `--assign none` + - an `--assign` value that matches no human — used to warn "leaving + unassigned" and create the issue anyway + - no `--assign` and an unresolvable API key owner (revoked token, failing + `viewer` lookup) — used to create the issue unowned with no warning at all + + Pass `--force` to create an unowned issue deliberately. `--delegate` does not + satisfy the check: it sets `delegateId`, not `assigneeId`, so pair it with + `--assign`. The check applies to `--from-file` bulk create per row; `--force` + waives it for the whole file, or set `"force": true` on a single row. Bulk + error text stays on one line so the tab-separated record is still parseable — + an `--assign` value echoed into that message is `repr`-escaped, so a tab or + newline in it can't split the record. + +### Fixed + +- **`--from-file` bulk output no longer corrupts its tab-separated records.** + Row titles were interpolated into the TSV raw, so a title containing a tab + added a phantom column and a title containing a newline split one row into + two — silently breaking any consumer doing `line.split("\t")`. Titles are now + escaped on the way out (both the echoed input title and the created issue's + title as returned by Linear). Predates the owner check; found reviewing it. + ## [0.20.0] - 2026-09-01 ### Added diff --git a/README.md b/README.md index 591d6cd..bc565b8 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ linear create "Sub-task" --parent ANT-42 # nested; prints a tip nudging a fl linear create "Roadmap item" --project "Phoenix" --milestone "v1.0" linear create "Ship it" --delegate droid # create + hand to an agent linear create --from-file plan.jsonl # bulk: one JSON object per line +linear create "Unowned" --assign none --force # refused without --force: every issue needs an owner linear projects # list projects + progress + issue count linear projects "Phoenix" # detail view: milestones with per-milestone % done @@ -149,6 +150,7 @@ The same CLI works whether you're typing or a subagent is. Driving Linear from e - **Assignee-as-queue.** `linear tasks` returns what *you* own in the active cycle. Widen with `--cycle all` (whole team) or `--cycle none` (backlog), or filter by `--assignee me|none|`. No dashboards, no saved views. - **Directory-aware scope.** When `agents projects` binds the current directory to a Linear project, `linear tasks` (and `--board`) auto-scope to that project — so an agent launched inside a project folder works that project's queue, not the whole workspace. `--all` shows every project, `--project X` overrides, and `autoScope: false` in `~/.linear-cli/config.json` disables it. Fail-open: with no `agents` CLI or no binding for the cwd, nothing changes. The `--json` output carries `project: {id, name, auto}` (null when unscoped). - **Milestones as deliverables.** `--milestone` scopes to one deliverable across all cycles; `--by-milestone` groups a project's issues by milestone (with a *No milestone* bucket for unmatched work), each row annotated with its cycle so you see which iteration a deliverable's work is scheduled in. `linear projects` / `milestones list` roll up per-milestone % done, so a deliverable's progress sits next to its target date. Scoping to `--project`/`--milestone` widens to all cycles by default (the whole deliverable, not just this cycle's slice). +- **Every issue gets an owner.** `create` refuses to make an unassigned issue and says why. The default already assigns the API key owner, so this only fires when the assignee would be empty — `--assign none`, an `--assign` value matching no human (which used to warn and create it unowned anyway), or an unresolvable API key owner (which used to create it unowned with no warning at all). Fix it with `--assign `, or say `--force` when it genuinely has no owner yet. `--delegate` doesn't count: it sets the agent, not the owner, so pair it with `--assign`. Keeps the board's "No assignee" bucket from filling with tickets nobody picks up. - **Native agent delegation.** `linear update ANT-42 --delegate claude` sets Linear's `delegateId`: the human stays assignee, the agent becomes delegate, and review ownership stays clear. - **One ownership model.** `delegate` is the only thing that owns an issue. `linear tasks --agent claude` filters to issues delegated to Claude; the default view adds the issues nobody has been delegated (`delegate` is null). `linear tasks --board` groups its columns by delegate. There is no label lane — an unknown `--agent` aborts rather than printing an empty queue. - **Proof-first completion.** `--done --proof ` uploads attachments, records links, and appends notes in one call — so reviewers see evidence without digging. diff --git a/linear b/linear index e3000ae..09ad808 100755 --- a/linear +++ b/linear @@ -3013,11 +3013,49 @@ def _apply_update(args, cfg, api_key, team_id, issue, single=True, # create # --------------------------------------------------------------------------- +def tsv_field(value: str) -> str: + """Make a value safe to place in one field of a tab-separated record. + + Bulk `--from-file` output is TSV, so a tab in a value would add a phantom + column and a newline would split the row. Both come straight from the + caller's JSONL (title, description) or from Linear (issue title), so + neither is trustworthy — escape at the point of formatting. + """ + return (str(value) + .replace("\t", "\\t") + .replace("\r\n", "\\n") + .replace("\n", "\\n") + .replace("\r", "\\n")) + + +def unassigned_error(title: str, reason: str, multiline: bool = True) -> str: + """The message shown when a create would leave an issue with no assignee. + + Multi-line for interactive `linear create` (the operator reads it and fixes + the command); single-line for `--from-file`, whose per-row output is a + tab-separated record that a newline would corrupt. + """ + if not multiline: + return (f"unassigned issue refused: {reason}. Every ticket needs a clear owner — " + f"set \"assign\" to an email or name on this row (see `linear users`), " + f"or pass --force to create it unowned anyway.") + return ( + f"Refusing to create an unassigned issue: {title!r}\n" + f" Why: {reason}.\n" + f" Every ticket needs a clear owner. An unassigned issue is nobody's job — it\n" + f" lands in the board's \"No assignee\" bucket and nobody picks it up.\n" + f" Fix: --assign (run `linear users` to see who you can assign)\n" + f" Handing the work to an agent does not replace the owner: --delegate rides\n" + f" alongside --assign, it does not stand in for it.\n" + f" If it genuinely has no owner yet, say so explicitly: --force" + ) + + def _build_create_input(api_key: str, team_id: str, cfg: dict, fields: dict, verbose: bool = True) -> tuple[dict | None, str | None]: """Build an IssueCreateInput from a flat fields dict (keys match flag names: title, description, description_file, priority, parent, project, - milestone, status, cycle, assign, due_date, label). + milestone, status, cycle, assign, due_date, label, force). Returns (input_obj, None) on success or (None, error) on hard failure. Smart defaults applied silently. Warnings go to stderr when verbose=True. @@ -3102,19 +3140,31 @@ def _build_create_input(api_key: str, team_id: str, cfg: dict, fields: dict, warn(f"no {cycle_choice} cycle found; creating without cycle.") assign_arg = fields.get("assign") + unassigned_reason = "" if assign_arg is None: vid = get_viewer_id(api_key, cfg) if vid: input_obj["assigneeId"] = vid + else: + unassigned_reason = ("the API key owner could not be resolved, so there is " + "no default assignee") elif assign_arg.lower() == "none": - pass + unassigned_reason = "you passed --assign none" else: uid = resolve_assignee_id(api_key, assign_arg) if uid: input_obj["assigneeId"] = uid else: - warn(f"--assign '{assign_arg}' matched no human " - f"(try an email, a name, or 'linear users'); leaving unassigned.") + # !r, not '{}': this value is echoed back into the bulk --from-file + # error, which is one field of a tab-separated record. repr escapes + # any tab/newline in the value so it can't split the record. + unassigned_reason = (f"--assign {assign_arg!r} matched no human " + f"(try an email, a name, or `linear users`)") + + # An issue with no assignee is nobody's job — it sits on the board unowned + # and rots. Refuse by default; --force is the deliberate escape hatch. + if "assigneeId" not in input_obj and not fields.get("force"): + return None, unassigned_error(title, unassigned_reason, multiline=verbose) delegate_arg = fields.get("delegate") if delegate_arg and delegate_arg.lower() != "none": @@ -3161,7 +3211,7 @@ def _send_issue_create(api_key: str, input_obj: dict) -> tuple[dict | None, str def cmd_create(args, cfg, api_key, team_id): if args.from_file: - return _bulk_create(args.from_file, cfg, api_key, team_id) + return _bulk_create(args.from_file, cfg, api_key, team_id, force=args.force) fields = { "title": args.title, @@ -3178,6 +3228,7 @@ def cmd_create(args, cfg, api_key, team_id): "due_date": args.due_date, "label": args.label or [], "image": args.image or [], + "force": args.force, } input_obj, err = _build_create_input(api_key, team_id, cfg, fields, verbose=True) if err: @@ -3194,10 +3245,13 @@ def cmd_create(args, cfg, api_key, team_id): print(f"Created {issue['identifier']}: {issue['title']} [{meta}]") -def _bulk_create(path: str, cfg, api_key, team_id): +def _bulk_create(path: str, cfg, api_key, team_id, force: bool = False): """Bulk create from JSONL. Each line is a JSON object with field names matching the single-create flags. Continue-on-error; tab-separated output: - OKIDENTTITLE or ERROR-TITLEREASON.""" + OKIDENTTITLE or ERROR-TITLEREASON. + + `force` applies the --force owner-check bypass to every row; a row can also + carry its own "force": true.""" if path == "-": stream = sys.stdin else: @@ -3219,7 +3273,12 @@ def _bulk_create(path: str, cfg, api_key, team_id): err_count += 1 continue - title_hint = fields.get("title") or (fields.get("description", "")[:60] if fields.get("description") else "") + if force: + fields["force"] = True + title_hint = tsv_field( + fields.get("title") + or (fields.get("description", "")[:60] if fields.get("description") else "") + ) input_obj, err = _build_create_input(api_key, team_id, cfg, fields, verbose=False) if err: print(f"ERROR\t-\t{title_hint}\t{err}") @@ -3230,7 +3289,7 @@ def _bulk_create(path: str, cfg, api_key, team_id): print(f"ERROR\t-\t{title_hint}\t{err}") err_count += 1 continue - print(f"OK\t{issue['identifier']}\t{issue['title']}") + print(f"OK\t{issue['identifier']}\t{tsv_field(issue['title'])}") ok_count += 1 if path != "-": @@ -4805,7 +4864,8 @@ def main(): p_create.add_argument("--due-date", dest="due_date", help="Due date (YYYY-MM-DD)") p_create.add_argument("--assign", default=None, - help="Assign to a human by email or name, or 'none' (default: API key owner)") + help="Assign to a human by email or name (default: API key owner). " + "'none' leaves it unowned and requires --force.") p_create.add_argument("--delegate", default=None, help="Delegate the new issue to an agent by name (e.g. --delegate claude)") p_create.add_argument("--from-file", dest="from_file", default=None, @@ -4816,6 +4876,9 @@ def main(): help="Project name or ID") p_create.add_argument("--milestone", default=None, help="Project milestone name (resolved within --project if set)") + p_create.add_argument("--force", action="store_true", + help="Create even with no assignee. Without it, an issue that " + "would land unowned is refused — every ticket needs an owner.") # cycles (list / create / update). No delete: archiving a cycle discards # sprint history, so it's intentionally not exposed here. diff --git a/skill.md b/skill.md index 6fbc2fd..b66c25c 100644 --- a/skill.md +++ b/skill.md @@ -155,12 +155,45 @@ linear create "Item" --project "Phoenix" --milestone "v1.0" linear create --from-file plan.jsonl # bulk: one issue per JSON line ``` +### Every issue needs an owner + +`create` refuses to make an unassigned issue. With no `--assign`, the API key +owner becomes the assignee, so the ordinary case is owned without you doing +anything. It refuses whenever the assignee would end up empty: + +- `--assign none` +- an `--assign` value that matches no human +- no `--assign` *and* the API key owner can't be resolved (a revoked token or a + failing `viewer` lookup) — rare, but it used to produce an unowned issue with + no warning at all + +``` +linear create "Fix auth bug" --assign none +# Refusing to create an unassigned issue: 'Fix auth bug' +# Why: you passed --assign none. +# ... +# If it genuinely has no owner yet, say so explicitly: --force +``` + +Pick one: + +- `--assign ` — hand it to a specific human (`linear users` lists them). +- `--force` — create it unowned anyway. Deliberate, not a default. + +`--delegate ` is **not** a third option: it sets `delegateId`, never +`assigneeId`, so it does not clear the check. To have an agent do the work, +name the human who owns it too — `--assign bisma --delegate claude`. + +Unowned issues pile up in the board's "No assignee" bucket and nobody picks +them up, which is why this is a hard refusal rather than a warning. + Bulk output is tab-separated for easy parsing: ``` OK ANT-42 Fix auth ERROR - Other project 'Foo' not found ``` -Bad lines don't stop the run. +Bad lines don't stop the run. The owner check applies per row — set `"assign"` +on the row, or pass `--force` to waive it for the whole file. ## Multi-team workspaces diff --git a/test_linear.py b/test_linear.py index fb71e2e..480e707 100644 --- a/test_linear.py +++ b/test_linear.py @@ -442,6 +442,140 @@ def fake_upload_file(_api_key, path): linear_cli.get_cycle_id = original_get_cycle_id +class CreateRequiresOwnerTest(unittest.TestCase): + """An issue with no assignee is nobody's job. `create` refuses to make one + unless the caller says --force. See the "No assignee" bucket on the board.""" + + CFG = { + "states": {"Todo": {"id": "state-id", "type": "unstarted"}}, + "viewerId": "viewer-id", + } + + def setUp(self): + self._orig_cycle = linear_cli.get_cycle_id + self._orig_resolve = linear_cli.resolve_assignee_id + linear_cli.get_cycle_id = lambda _a, _t, _w: None + # Only "bisma" is a real human; anything else resolves to nobody. + linear_cli.resolve_assignee_id = ( + lambda _a, value: "bisma-id" if value == "bisma" else None + ) + + def tearDown(self): + linear_cli.get_cycle_id = self._orig_cycle + linear_cli.resolve_assignee_id = self._orig_resolve + + def _build(self, **fields): + base = {"title": "Fix the login redirect", "cycle": "active"} + base.update(fields) + return linear_cli._build_create_input( + "api-key", "team-id", dict(self.CFG), base, verbose=True + ) + + def test_assign_none_is_refused(self): + input_obj, err = self._build(assign="none") + self.assertIsNone(input_obj) + self.assertIn("Refusing to create an unassigned issue", err) + self.assertIn("you passed --assign none", err) + self.assertIn("--force", err) + self.assertIn("--assign ", err) + + def test_unresolvable_assignee_is_refused_not_silently_unassigned(self): + input_obj, err = self._build(assign="nobody@example.com") + self.assertIsNone(input_obj) + self.assertIn("matched no human", err) + self.assertIn("Refusing to create an unassigned issue", err) + + def test_missing_viewer_is_refused(self): + cfg_without_viewer = {"states": self.CFG["states"]} + orig_gql = linear_cli.gql + linear_cli.gql = lambda *_a, **_k: {"data": {"viewer": {}}} + try: + input_obj, err = linear_cli._build_create_input( + "api-key", "team-id", cfg_without_viewer, + {"title": "No owner anywhere", "cycle": "active"}, verbose=True, + ) + finally: + linear_cli.gql = orig_gql + self.assertIsNone(input_obj) + self.assertIn("the API key owner could not be resolved", err) + + def test_force_creates_the_unassigned_issue(self): + input_obj, err = self._build(assign="none", force=True) + self.assertIsNone(err) + self.assertNotIn("assigneeId", input_obj) + + def test_named_assignee_still_works(self): + input_obj, err = self._build(assign="bisma") + self.assertIsNone(err) + self.assertEqual(input_obj["assigneeId"], "bisma-id") + + def test_default_assigns_the_api_key_owner(self): + input_obj, err = self._build() + self.assertIsNone(err) + self.assertEqual(input_obj["assigneeId"], "viewer-id") + + def test_delegate_alone_does_not_satisfy_the_owner_check(self): + # --delegate sets delegateId, never assigneeId, so it cannot stand in + # for an assignee. The message must not offer it as a lone fix. + input_obj, err = self._build(assign="none", delegate="claude") + self.assertIsNone(input_obj) + self.assertIn("does not replace the owner", err) + self.assertIn("rides", err) + + def test_bulk_error_stays_on_one_line(self): + # --from-file prints ERROR-TITLEREASON; a newline in the + # reason would corrupt that record. + _, err = linear_cli._build_create_input( + "api-key", "team-id", dict(self.CFG), + {"title": "Bulk row", "cycle": "active", "assign": "none"}, verbose=False, + ) + self.assertNotIn("\n", err) + self.assertIn("unassigned issue refused", err) + self.assertIn("--force", err) + + def test_bulk_error_survives_tabs_and_newlines_in_the_assign_value(self): + # The unresolvable-assignee reason echoes the caller's --assign value + # back. That value is user data: a tab would add a phantom column to + # the TSV record and a newline would split it into two rows. + for hostile in ("evil\tvalue", "evil\nvalue", "evil\r\nvalue"): + with self.subTest(assign=hostile): + _, err = linear_cli._build_create_input( + "api-key", "team-id", dict(self.CFG), + {"title": "Bulk row", "cycle": "active", "assign": hostile}, + verbose=False, + ) + self.assertNotIn("\t", err) + self.assertNotIn("\n", err) + self.assertNotIn("\r", err) + self.assertIn("matched no human", err) + + +class BulkTsvFieldTest(unittest.TestCase): + """Bulk --from-file output is a tab-separated record. Titles come from the + caller's JSONL and from Linear, so neither may carry a raw tab/newline.""" + + def test_tabs_and_newlines_are_escaped(self): + for hostile in ("a\tb", "a\nb", "a\r\nb", "a\rb"): + with self.subTest(value=hostile): + out = linear_cli.tsv_field(hostile) + self.assertNotIn("\t", out) + self.assertNotIn("\n", out) + self.assertNotIn("\r", out) + + def test_crlf_collapses_to_one_escape(self): + self.assertEqual(linear_cli.tsv_field("a\r\nb"), "a\\nb") + + def test_ordinary_titles_are_untouched(self): + self.assertEqual(linear_cli.tsv_field("Fix the login redirect"), + "Fix the login redirect") + + def test_bulk_row_keeps_four_fields_with_a_hostile_title(self): + title_hint = linear_cli.tsv_field("evil\ttitle\nsplit") + row = f"ERROR\t-\t{title_hint}\tsome reason" + self.assertEqual(len(row.split("\t")), 4) + self.assertEqual(len(row.splitlines()), 1) + + class MilestoneRollupTest(unittest.TestCase): def test_rollup_aggregates_by_milestone_with_none_bucket(self): # One page of a project's issues across two milestones + an unassigned