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..74248b4 100755 --- a/linear +++ b/linear @@ -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, @@ -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 []: @@ -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 diff --git a/test_linear.py b/test_linear.py index a74e70c..7f7e316 100644 --- a/test_linear.py +++ b/test_linear.py @@ -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"):