From 3768c4cf4aa4da5d43a52eee0ed21ba628537d9b Mon Sep 17 00:00:00 2001 From: Muqsit Date: Tue, 15 Sep 2026 00:58:29 -0700 Subject: [PATCH 1/2] feat(labels): an unknown --label names the team's real labels create and update printed "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 labels, sorted, with the count and the full-list command, so the fix at that moment is picking one, not minting one. (PHNX-4105) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FQ3w5SAvum8nShzmBo21Ar --- CHANGELOG.md | 9 +++++++++ linear | 16 ++++++++++++++-- test_linear.py | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4dc25b..19be349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/linear b/linear index 14da2c3..a921ff9 100755 --- a/linear +++ b/linear @@ -1094,6 +1094,18 @@ 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`.""" + 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)} " + f"({shown}); full list: linear labels.") + + def _sort_by_recent(nodes: list[dict]) -> list[dict]: return sorted( nodes, @@ -3477,7 +3489,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 []: @@ -4010,7 +4022,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 diff --git a/test_linear.py b/test_linear.py index a74e70c..c1dda32 100644 --- a/test_linear.py +++ b/test_linear.py @@ -3710,6 +3710,46 @@ 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 (Bug, engineering, Feature, growth, Improvement, security)", msg) + self.assertIn("full list: linear labels.", msg) + self.assertNotIn("Did you mean", 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 (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"): From 552a087ee66882a70586494229268df73c8e5304 Mon Sep 17 00:00:00 2001 From: Muqsit Date: Tue, 15 Sep 2026 01:04:38 -0700 Subject: [PATCH 2/2] fixup: empty-team wording and the missing noun after the label count Review finding on PR #57. (PHNX-4105) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FQ3w5SAvum8nShzmBo21Ar --- linear | 5 ++++- test_linear.py | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/linear b/linear index a921ff9..74248b4 100755 --- a/linear +++ b/linear @@ -1099,10 +1099,13 @@ def _unknown_label_message(label: str, names: list[str]) -> str: 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)} " + f"Labels are owner-managed: pick one of the team's {len(names)} labels " f"({shown}); full list: linear labels.") diff --git a/test_linear.py b/test_linear.py index c1dda32..7f7e316 100644 --- a/test_linear.py +++ b/test_linear.py @@ -3719,10 +3719,15 @@ class UnknownLabelMessageTests(unittest.TestCase): 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 (Bug, engineering, Feature, growth, Improvement, security)", msg) + 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) @@ -3730,7 +3735,7 @@ def test_near_miss_gets_a_did_you_mean(self): 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 (label-0, label-1, label-10, ", msg) + self.assertIn("team's 20 labels (label-0, label-1, label-10, ", msg) self.assertIn("label-19, …)", msg) self.assertNotIn("label-2,", msg)