From 0eddb55ca4fa116008ae0824983898a882dc94a7 Mon Sep 17 00:00:00 2001 From: Muqsit Date: Sat, 5 Sep 2026 19:44:45 -0700 Subject: [PATCH 1/3] feat(create): refuse to create an unassigned issue without --force MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An issue with no assignee is nobody's job — it lands in the board's "No assignee" bucket and nobody picks it up. `linear create` now fails closed with a message that says why it refused and how to fix it, instead of silently producing an unowned ticket. The default path is unchanged: with no --assign, the API key owner is still the assignee. Two paths now error where they previously did not: - `--assign none` - an `--assign` value matching no human, which used to warn "leaving unassigned" and create the issue anyway `--force` is the deliberate escape hatch. The check applies per row to `--from-file` bulk create; `--force` waives it for the whole file, or a row can carry "force": true. Bulk error text stays on one line so the tab-separated record is still parseable. --- CHANGELOG.md | 15 +++++++++ README.md | 2 ++ linear | 57 +++++++++++++++++++++++++++++----- skill.md | 27 +++++++++++++++- test_linear.py | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b49210..58535a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ 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 default path is unaffected: with no `--assign`, the API key owner is still + the assignee. Two paths now error where they previously did not: `--assign + none`, and an `--assign` value that matches no human (which used to warn + "leaving unassigned" and create the issue anyway). Pass `--force` to create an + unowned issue deliberately. 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. + ## [0.20.0] - 2026-09-01 ### Added diff --git a/README.md b/README.md index 591d6cd..0f15804 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`, or an `--assign` value matching no human (which used to warn and create it unowned anyway). Fix it with `--assign `, or say `--force` when it genuinely has no owner yet. 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..33093f0 100755 --- a/linear +++ b/linear @@ -3013,11 +3013,33 @@ def _apply_update(args, cfg, api_key, team_id, issue, single=True, # create # --------------------------------------------------------------------------- +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" --delegate hands the work to an agent; the human stays owner\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 +3124,28 @@ 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.") + unassigned_reason = (f"--assign '{assign_arg}' 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 +3192,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 +3209,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 +3226,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,6 +3254,8 @@ def _bulk_create(path: str, cfg, api_key, team_id): err_count += 1 continue + if force: + fields["force"] = True title_hint = 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: @@ -4805,7 +4842,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 +4854,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..ab0c3ce 100644 --- a/skill.md +++ b/skill.md @@ -155,12 +155,37 @@ 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 default is always owned. It only refuses +when the assignee would end up empty: `--assign none`, or an `--assign` value +that matches no human. + +``` +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). +- `--delegate ` — hand the work to an agent; the human stays the owner. +- `--force` — create it unowned anyway. Deliberate, not a default. + +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..f1a2e21 100644 --- a/test_linear.py +++ b/test_linear.py @@ -442,6 +442,90 @@ 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_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) + + 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 From b5ed0aa432fac1e3c615117c4190e2922c7992f4 Mon Sep 17 00:00:00 2001 From: Muqsit Date: Sat, 5 Sep 2026 19:56:32 -0700 Subject: [PATCH 2/3] fix(create): review findings on the unassigned-issue guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the non-author review of #44: 1. The refusal message and skill.md offered `--delegate ` as a standalone fix. It isn't one — delegate sets delegateId, never assigneeId, so following that advice got you refused again with the identical message. Both now say delegate rides alongside --assign rather than standing in for it. Covered by a new test. 2. The unresolvable-assignee reason interpolated the caller's --assign value raw. In bulk --from-file that value lands in a tab-separated record, so a tab or newline in it split the record into phantom columns or rows — breaking the exact single-line invariant this change claims to hold. Now interpolated with !r, which escapes both. Covered by a new test over tab / newline / CRLF values. 3. CHANGELOG, skill.md, and README each said only two paths newly error. There are three: an unresolvable API key owner also refuses now, and that case previously created an unowned issue with no warning at all. Documented on all three surfaces. --- CHANGELOG.md | 24 ++++++++++++++++-------- README.md | 2 +- linear | 10 +++++++--- skill.md | 16 ++++++++++++---- test_linear.py | 24 ++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58535a5..1d843ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,14 +13,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 default path is unaffected: with no `--assign`, the API key owner is still - the assignee. Two paths now error where they previously did not: `--assign - none`, and an `--assign` value that matches no human (which used to warn - "leaving unassigned" and create the issue anyway). Pass `--force` to create an - unowned issue deliberately. 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. + 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. ## [0.20.0] - 2026-09-01 diff --git a/README.md b/README.md index 0f15804..bc565b8 100644 --- a/README.md +++ b/README.md @@ -150,7 +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`, or an `--assign` value matching no human (which used to warn and create it unowned anyway). Fix it with `--assign `, or say `--force` when it genuinely has no owner yet. Keeps the board's "No assignee" bucket from filling with tickets nobody picks up. +- **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 33093f0..cddf437 100755 --- a/linear +++ b/linear @@ -3029,8 +3029,9 @@ def unassigned_error(title: str, reason: str, multiline: bool = True) -> str: 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" --delegate hands the work to an agent; the human stays owner\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" ) @@ -3139,7 +3140,10 @@ def _build_create_input(api_key: str, team_id: str, cfg: dict, fields: dict, if uid: input_obj["assigneeId"] = uid else: - unassigned_reason = (f"--assign '{assign_arg}' matched no human " + # !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 diff --git a/skill.md b/skill.md index ab0c3ce..b66c25c 100644 --- a/skill.md +++ b/skill.md @@ -158,9 +158,14 @@ linear create --from-file plan.jsonl # bulk: one issue per JSON ### Every issue needs an owner `create` refuses to make an unassigned issue. With no `--assign`, the API key -owner becomes the assignee — so the default is always owned. It only refuses -when the assignee would end up empty: `--assign none`, or an `--assign` value -that matches no human. +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 @@ -173,9 +178,12 @@ linear create "Fix auth bug" --assign none Pick one: - `--assign ` — hand it to a specific human (`linear users` lists them). -- `--delegate ` — hand the work to an agent; the human stays the owner. - `--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. diff --git a/test_linear.py b/test_linear.py index f1a2e21..5ca7884 100644 --- a/test_linear.py +++ b/test_linear.py @@ -514,6 +514,14 @@ def test_default_assigns_the_api_key_owner(self): 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. @@ -525,6 +533,22 @@ def test_bulk_error_stays_on_one_line(self): 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 MilestoneRollupTest(unittest.TestCase): def test_rollup_aggregates_by_milestone_with_none_bucket(self): From 995c599ca0623cb434648b01fe1822a0db9f4444 Mon Sep 17 00:00:00 2001 From: Muqsit Date: Sat, 5 Sep 2026 20:00:45 -0700 Subject: [PATCH 3/3] fix(bulk): escape tabs and newlines in --from-file TSV titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewing the assign-value escaping turned up the same defect one field over: `title_hint` and the created issue's title were interpolated into the tab-separated bulk record raw. A title with a tab added a phantom column; a title with a newline split one row into two. Any consumer doing line.split("\t") — the shape the README and skill.md document — silently gets garbage. Adds tsv_field() and applies it to both title fields, so the single-line-record invariant this branch documents actually holds for the whole row rather than just the reason column. Predates the owner check; fixing it here because the CHANGELOG entry added on this branch is what claims the record stays parseable. --- CHANGELOG.md | 9 +++++++++ linear | 22 ++++++++++++++++++++-- test_linear.py | 26 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d843ee..fdb0b9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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/linear b/linear index cddf437..09ad808 100755 --- a/linear +++ b/linear @@ -3013,6 +3013,21 @@ 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. @@ -3260,7 +3275,10 @@ def _bulk_create(path: str, cfg, api_key, team_id, force: bool = False): if force: fields["force"] = True - title_hint = fields.get("title") or (fields.get("description", "")[:60] if fields.get("description") else "") + 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}") @@ -3271,7 +3289,7 @@ def _bulk_create(path: str, cfg, api_key, team_id, force: bool = False): 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 != "-": diff --git a/test_linear.py b/test_linear.py index 5ca7884..480e707 100644 --- a/test_linear.py +++ b/test_linear.py @@ -550,6 +550,32 @@ def test_bulk_error_survives_tabs_and_newlines_in_the_assign_value(self): 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