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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **An unknown `--label` now says which labels exist.** `create` and `update`
used to print `label 'x' not found, skipping.` and agents answered it with
`linear labels create x`. The warning now adds a did-you-mean for a near
miss and the team's actual labels ("Labels are owner-managed: pick one of
the team's 25 (…); full list: linear labels"), so the fix at that moment is
picking one, not minting one. (PHNX-4105)

## [0.24.1] - 2026-09-15

### Changed
Expand Down
19 changes: 17 additions & 2 deletions linear
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,21 @@ def _suggest(value: str, names: list[str]) -> str:
return f" Did you mean: {', '.join(close)}?" if close else ""


def _unknown_label_message(label: str, names: list[str]) -> str:
"""Why a label was skipped and what to use instead. Labels are the owner's
taxonomy, so the fix is picking an existing one, never creating it — the
message says so at the moment an agent would otherwise reach for
`linear labels create`."""
if not names:
return (f"label '{label}' not found, skipping. The team has no labels yet; "
"labels are owner-managed, so ask the owner rather than creating one.")
names = sorted(names, key=str.lower) # stable, scannable; the API order is creation order
shown = ", ".join(names[:12]) + (", …" if len(names) > 12 else "")
return (f"label '{label}' not found, skipping.{_suggest(label, names)} "
f"Labels are owner-managed: pick one of the team's {len(names)} labels "
f"({shown}); full list: linear labels.")


def _sort_by_recent(nodes: list[dict]) -> list[dict]:
return sorted(
nodes,
Expand Down Expand Up @@ -3477,7 +3492,7 @@ def _apply_update(args, cfg, api_key, team_id, issue, single=True,
label_ids.append(label_map[lbl])
added.append(lbl)
else:
print(f"Warning: label '{lbl}' not found, skipping.", file=sys.stderr)
print("Warning: " + _unknown_label_message(lbl, list(label_map)), file=sys.stderr)

removed = []
for lbl in args.unlabel or []:
Expand Down Expand Up @@ -4010,7 +4025,7 @@ def _build_create_input(api_key: str, team_id: str, cfg: dict, fields: dict,
if lbl in label_map:
label_ids.append(label_map[lbl])
else:
warn(f"label '{lbl}' not found, skipping.")
warn(_unknown_label_message(lbl, list(label_map)))
if label_ids:
input_obj["labelIds"] = label_ids

Expand Down
45 changes: 45 additions & 0 deletions test_linear.py
Original file line number Diff line number Diff line change
Expand Up @@ -3710,6 +3710,51 @@ def test_live_ollama_ranks_the_matching_ticket_first(self):
self.assertAlmostEqual(a, b, places=2)


class UnknownLabelMessageTests(unittest.TestCase):
"""A skipped label names the real labels so the fix is picking one, not
creating one."""

NAMES = ["engineering", "Bug", "Feature", "Improvement", "growth", "security"]

def test_names_the_team_labels_and_the_full_list_command(self):
msg = linear_cli._unknown_label_message("prix-api-audit", self.NAMES)
self.assertTrue(msg.startswith("label 'prix-api-audit' not found, skipping."))
self.assertIn("Labels are owner-managed: pick one of the team's 6 labels (Bug, engineering, Feature, growth, Improvement, security)", msg)
self.assertIn("full list: linear labels.", msg)
self.assertNotIn("Did you mean", msg)

def test_empty_team_says_so_instead_of_a_zero_count(self):
msg = linear_cli._unknown_label_message("foo", [])
self.assertIn("The team has no labels yet", msg)
self.assertNotIn("team's 0", msg)

def test_near_miss_gets_a_did_you_mean(self):
msg = linear_cli._unknown_label_message("bugs", self.NAMES)
self.assertIn("Did you mean: Bug?", msg)

def test_long_lists_are_elided(self):
names = [f"label-{i}" for i in range(20)]
msg = linear_cli._unknown_label_message("zzz", names)
self.assertIn("team's 20 labels (label-0, label-1, label-10, ", msg)
self.assertIn("label-19, …)", msg)
self.assertNotIn("label-2,", msg)

def test_create_path_prints_the_hint_and_still_builds_the_input(self):
original = linear_cli.list_team_labels
linear_cli.list_team_labels = lambda _a, _t: [{"name": n, "id": f"id-{n}"} for n in self.NAMES]
err = io.StringIO()
try:
with contextlib.redirect_stderr(err):
obj, error = linear_cli._build_create_input(
"k", "team-id", {}, {"title": "x", "skip_milestone": True, "force": True,
"label": ["engineering", "prix-api-audit"]})
finally:
linear_cli.list_team_labels = original
self.assertIsNone(error)
self.assertEqual(obj["labelIds"], ["id-engineering"])
self.assertIn("label 'prix-api-audit' not found, skipping. Labels are owner-managed", err.getvalue())


# --- board cache -------------------------------------------------------------

def _board_node(ident, title, updated, project_id="proj-a"):
Expand Down
Loading