Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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|<email>`. 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 <email|name>`, 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 <file|url|text>` uploads attachments, records links, and appends notes in one call — so reviewers see evidence without digging.
Expand Down
83 changes: 73 additions & 10 deletions linear
Original file line number Diff line number Diff line change
Expand Up @@ -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 <email|name> (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.
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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:
OK<TAB>IDENT<TAB>TITLE or ERROR<TAB>-<TAB>TITLE<TAB>REASON."""
OK<TAB>IDENT<TAB>TITLE or ERROR<TAB>-<TAB>TITLE<TAB>REASON.

`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:
Expand All @@ -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 "<no title>")
if force:
fields["force"] = True
title_hint = tsv_field(
fields.get("title")
or (fields.get("description", "")[:60] if fields.get("description") else "<no title>")
)
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}")
Expand All @@ -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 != "-":
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
35 changes: 34 additions & 1 deletion skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <email|name>` — hand it to a specific human (`linear users` lists them).
- `--force` — create it unowned anyway. Deliberate, not a default.

`--delegate <agent>` 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

Expand Down
Loading
Loading