Skip to content

feat(create): refuse to create an unassigned issue without --force - #44

Merged
muqsitnawaz merged 3 commits into
mainfrom
guard-unassigned-create
Sep 6, 2026
Merged

feat(create): refuse to create an unassigned issue without --force#44
muqsitnawaz merged 3 commits into
mainfrom
guard-unassigned-create

Conversation

@muqsitnawaz

Copy link
Copy Markdown
Contributor

Why

The board fills with tickets nobody owns. Grouping the active cycle by assignee shows a "No assignee" bucket that just sits there — an unassigned issue is nobody's job, so nobody picks it up.

linear create had two paths that produced unowned tickets, one of them silently:

  • --assign none — created it unassigned, no comment
  • --assign <value> that matched no human — printed Warning: ... leaving unassigned. and created the issue anyway (linear:3116 before this change)

What changed

create now fails closed when the issue would land with no assignee, and says why.

$ linear create "Guard smoke test — should never be created" --assign none
Refusing to create an unassigned issue: 'Guard smoke test — should never be created'
  Why: you passed --assign none.
  Every ticket needs a clear owner. An unassigned issue is nobody's job — it
  lands in the board's "No assignee" bucket and nobody picks it up.
  Fix:  --assign <email|name>     (run `linear users` to see who you can assign)
        --delegate <agent>        hands the work to an agent; the human stays owner
  If it genuinely has no owner yet, say so explicitly: --force
$ echo $?
1

The unresolvable-assignee path gets the same treatment, with its own reason line:

$ linear create "Guard smoke — bogus assignee" --assign "not-a-real-person@nowhere.test"
Refusing to create an unassigned issue: 'Guard smoke — bogus assignee'
  Why: --assign 'not-a-real-person@nowhere.test' matched no human (try an email, a name, or `linear users`).
  ...

--force is the deliberate escape hatch:

$ linear create "TEMP smoke: --force unowned escape hatch" --assign none --force --priority low --cycle none
Created PHNX-3981: TEMP smoke: --force unowned escape hatch  [no cycle | unassigned]

The default path is unchanged — with no --assign, the API key owner is still the assignee, so ordinary linear create "..." never trips the guard:

$ linear create "TEMP smoke: owner-guard positive control" --priority low --cycle none
Created PHNX-3980: TEMP smoke: owner-guard positive control  [no cycle | Muqsit]

Both smoke tickets (PHNX-3980, PHNX-3981) were canceled immediately after.

Bulk (--from-file)

The check applies per row. Bulk output is a tab-separated record, so the reason is rendered on one line there rather than as the multi-line block — a newline would corrupt the record. --force waives the check for the whole file; a single row can also carry "force": true.

$ linear create --from-file /tmp/bulk-guard.jsonl
ERROR	-	bulk unowned row A	unassigned issue refused: you passed --assign none. Every ticket needs a clear owner — set "assign" to an email or name on this row (see `linear users`), or pass --force to create it unowned anyway.
ERROR	-	bulk unowned row B	unassigned issue refused: --assign 'ghost@nowhere.test' matched no human (try an email, a name, or `linear users`). Every ticket needs a clear owner — set "assign" to an email or name on this row (see `linear users`), or pass --force to create it unowned anyway.

0 created, 2 failed.

Tests

7 new cases in CreateRequiresOwnerTest, covering all three unowned reasons, the --force bypass, the named-assignee and default-owner happy paths, and the single-line invariant for bulk error text.

$ python3 -m unittest test_linear
Ran 139 tests in 0.023s
OK

Docs

README.md (usage line + "For humans and agents" bullet), skill.md (new "Every issue needs an owner" section — this is what agents read), CHANGELOG.md under Unreleased, and both --assign / --force help strings.

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.
@prix-cloud

prix-cloud Bot commented Sep 6, 2026

Copy link
Copy Markdown

VERDICT: APPROVE

Code Reviewer

Verdict: Ready to merge

Build: N/A — single-file Python CLI (no build step)
Tests: 139 passed, 0 failed, 0 skipped

Instruction files read

None found — the repo has no CLAUDE.md, AGENTS.md, or GEMINI.md at the root or in any touched directory. The repo is a standalone Python script with no declared build process.

Changes that work well

  • Clear fail-closed semantics. The two previously silent paths (--assign none, unresolvable --assign <value>) now error with a specific reason string and a structured multi-line message pointing to the fix. The third edge case (API key owner unresolvable) is also caught.
  • Test coverage is thorough. 7 new test cases cover all three refusal reasons, the --force bypass, the named-assignee and default-owner happy paths, and the single-line invariant for bulk mode. No regressions in existing tests.
  • Bulk compatibility is handled. The single-line error format for --from-file is verified by test_bulk_error_stays_on_one_line, and --force propagates both from the CLI flag and a per-row "force": true field.
  • Error message formatting is consistent. The trailing period is applied uniformly by the template (f" Why: {reason}.\n") and not duplicated in the callers.
  • Documentation is updated in all four expected places: CHANGELOG.md, README.md, skill.md, and the --assign/--force help strings.

Issues that need attention

None. The diff is focused, well-structured, and every claim in the PR description is backed by code or tests.

Things to verify manually

  • The --delegate line in the error message says "... the human stays owner" — worth a quick smoke test that --assign bisma --delegate claude sets both assigneeId and delegateId correctly, but the logic predates this change and isn't touched by it.

Reviewed by [Code Reviewer] — actually ran the tests on this branch. 139/139 pass.

Three findings from the non-author review of #44:

1. The refusal message and skill.md offered `--delegate <agent>` 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.
@prix-cloud

prix-cloud Bot commented Sep 6, 2026

Copy link
Copy Markdown

VERDICT: APPROVE

Code Reviewer

Verdict: Ready to merge

Build: Not applicable — single-file Python CLI script, no build step (syntax-checked both linear and test_linear.py: AST parses cleanly)

Tests: 141 passed, 0 failed, 0 skipped (0.024s)

Operating instructions read: none exist in this repo (no CLAUDE.md/AGENTS.md). Conventions documented in skill.md and README.md — both updated by the PR.

Changes that work well

  • Three failure paths, one guard. The _build_create_input function now tracks an unassigned_reason through all three paths that would produce an unowned issue: --assign none, an unresolvable --assign value, and a missing viewer ID. A single guard at the end (if "assigneeId" not in input_obj and not fields.get("force")) catches all three — clean, not fragile.

  • repr() escaping for hostile bulk input. The unresolvable-assignee error message uses {assign_arg!r} instead of bare interpolation, which means a tab or newline in the user-supplied value cannot split the tab-separated bulk record. The test test_bulk_error_survives_tabs_and_newlines_in_the_assign_value covers this explicitly. Good defensive measure for user-supplied data flowing into a structured output format.

  • --delegate does not satisfy the owner check. The delegate handling runs after the unassigned guard, so --delegate claude without --assign still triggers the refusal. The error message explicitly tells the user this. The test test_delegate_alone_does_not_satisfy_the_owner_check verifies it. This is correct: delegateId is not assigneeId.

  • Bulk force propagation. --force at the top level sets fields["force"] = True on every bulk row, while per-row "force": true in JSONL also works without the global flag. The two compose correctly (global always wins, per-row works without it).

  • Full test coverage. 7 new test methods covering all three refusal paths, the --force bypass, the named-assignee and default-owner happy paths, the --delegate interaction, and the single-line invariant for bulk output. Each test verifies both the error/no-error distinction and key message content.

Issues that need attention

None. The diff is well-scoped, correctly implemented, and thoroughly tested.

Things to verify manually

  • The error messages render correctly on a real terminal (multi-line for interactive, single-line for bulk). The PR author's smoke-test output in the description confirms this.
  • The --force escape hatch is genuinely a last resort — the error message frames it as "if it genuinely has no owner yet, say so explicitly: --force", which is the right tone.

Reviewed by Code Reviewer — actually ran the build and tests on this branch.

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.
@muqsitnawaz

Copy link
Copy Markdown
Contributor Author

Non-author review — verdict: READY TO MERGE

Reviewed by the code-reviewer subagent (non-author), two rounds. Round 1 returned CHANGES REQUESTED with two blockers and one should-fix; all three were fixed in b5ed0aa and re-verified by execution against the pushed diff, not against a summary.

Round 1 findings — all closed

BLOCKER — --delegate was offered as a standalone fix but doesn't clear the check. The gate tests "assigneeId" not in input_obj; --delegate only ever writes delegateId. Following the suggested fix literally (--assign none --delegate claude) got you refused again with the identical message. Message, skill.md, and README.md now state that --delegate rides alongside --assign rather than standing in for it. Verified: --delegate claude alone → REFUSED; --assign bisma --delegate claudeassignee=bisma-id delegate=claude-id.

BLOCKER — TSV injection through the --assign value. The unresolvable-assignee reason interpolated the caller's value raw. In bulk --from-file that value lands in a tab-separated record, so a tab added a phantom column and a newline split the row — breaking the exact invariant this PR claims. Now interpolated with !r. The reviewer verified repr() cannot emit a raw \t, \n, or \r for any str input, including U+2028/U+2029.

SHOULD — docs undercounted the refusal paths. There are three, not two: an unresolvable API key owner also refuses now, and that case previously created an unowned issue with no warning at all. Documented in CHANGELOG.md, skill.md, and README.md.

Adjacent defect found and fixed

The reviewer flagged that title_hint (and the created issue's title) were interpolated into the same TSV row raw — the identical defect class, one field over, predating this branch. Fixed in the final commit rather than deferred, because the CHANGELOG entry added on this branch is what claims that record stays parseable. Added tsv_field() and applied it to both title fields.

Real bulk row with a hostile title — 4 fields, 1 line:

$ printf '{"title":"evil\\ttitle\\nsplit","assign":"none"}\n' | linear create --from-file -
fields=4  'ERROR\t-\tevil\\ttitle\\nsplit\tunassigned issue refused: you passed --assign none. ...'

State at merge

  • python3 -m unittest test_linear145 tests, OK (132 on main)
  • All 5 CI checks pass on the head commit
  • Verified end to end against the live board: refusal on --assign none and on an unresolvable assignee; --force created PHNX-3981 unassigned; the default path created PHNX-3980 owned by Muqsit. Both smoke tickets canceled.

Filtered as non-defects across both rounds: args.force namespace reachability (only p_create defines it and only it routes to cmd_create), --force name collision (single definition in the file), other _build_create_input / _bulk_create callers bypassing the gate (exactly the two covered call sites), cmd_update --assign none (different command, untouched — see note below), JSON "force": "false" string truthiness, and setUp/tearDown vs try/finally test style.

Known gap, deliberately out of scope: linear update <id> --assign none can still strip the owner off an existing issue. This PR gates creation only.

@muqsitnawaz
muqsitnawaz merged commit 11c1ef3 into main Sep 6, 2026
5 checks passed
muqsitnawaz added a commit that referenced this pull request Sep 6, 2026
Three findings from the non-author review of #44:

1. The refusal message and skill.md offered `--delegate <agent>` 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.
@prix-cloud

prix-cloud Bot commented Sep 6, 2026

Copy link
Copy Markdown

VERDICT: APPROVE

Code Reviewer

Verdict: Ready to merge — clean design, thorough tests, all 145 pass.

Build: N/A (dependency-free Python script — no build step)
Tests: 145/145 passed (7 new owner-guard tests + 138 existing, all green)

Operating instructions read

No AGENTS.md/CLAUDE.md/GEMINI.md exist in this repo. The repo is a standalone Python script with stdlib-only tests — no special build conventions to discover.

Changes that work well

  • Fails closed, not open. The old behavior warned and created the issue anyway. The new behavior refuses unless --force is passed. That's the right default for a CLI whose whole purpose is keeping boards organized.
  • Three distinct failure paths, each with its own clear reason. --assign none, unresolvable --assign <name>, and unresolvable API key owner each get a reason tailored to what went wrong. This is better UX than a generic "no assignee" error.
  • Bulk mode (TSV) is handled correctly. verbose=False controls both the warn() no-op and the multiline parameter of unassigned_error, so bulk errors stay on one line, keeping the tab-separated record parseable. This is a subtle correctness concern that the author got right.
  • tsv_field is a nice incidental fix. Tab/newline escaping for bulk output titles was pre-existing broken behavior, caught during this change and fixed. The repr() escape for the unresolvable-assignee value in the error message further bulletproofs the TSV format.
  • --delegate does NOT satisfy the owner check. Setting delegateId without assigneeId is still refused, with a message saying so. The test (test_delegate_alone_does_not_satisfy_the_owner_check) verifies this explicitly. This is the correct semantic — delegate ≠ owner.
  • All seven new tests are meaningful. Each exercises a distinct code path: three refusal reasons, the --force override, the named-assignee happy path, the default-owner happy path, the delegate-does-not-satisfy case, and two bulk error-formatting invariants (no newlines, tab/newline safety for hostile input). No test is redundant.
  • _bulk_create signature change is backward-compatible. The new force parameter defaults to False, so no existing callers break.

Issues that need attention

None.

Notes

  • The CHANGELOG entry is well-structured under Unreleased > Changed with a separate Fixed for the TSV escaping bug found during this change. The skill.md docs are more detailed than the README, which is appropriate — skill.md is what agents read. The --assign and --force help strings match the actual behavior exactly.
  • No stubs, no TODOs, no environment variables introduced.

Reviewed by Code Reviewer — actually ran the build and tests on this branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant