diff --git a/CHANGELOG.md b/CHANGELOG.md index cab9385..5d9aa4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.0] - 2026-09-01 + +### Added + +- **`linear tasks` auto-scopes to the current directory's project.** With no + `--project` and no `--all`, the queue (and `--board`) now scope to the Linear + project bound to the current working directory, so an agent launched inside a + project folder sees that project's work instead of the entire workspace — the + fix for agents picking up tasks from the wrong project. The directory→project + mapping comes from the `agents projects` CLI (`for-cwd` does a longest-match + over every bound root and monorepo subpath), consulted via two bounded, + fail-open subprocess calls: no `agents` on `PATH`, no def for this cwd, or a + slow/broken call leaves the queue unscoped exactly as before. A cwd binding + that resolves to a project id which is not a live project on the team (a + renamed/recreated project or a stale recorded id) is also validated fail-open + — it falls back to the whole-team view with a one-line note, never aborting + the command the way a mistyped explicit `--project` does. Overrides: + `--all` (whole team), `--project X` (a specific project), or `autoScope: false` + in `~/.linear-cli/config.json` (disable globally). The `--json` output gains a + `project: {id, name, auto}` field (null when unscoped) so consumers can tell an + auto-scoped queue from the full workspace. + ## [0.19.1] - 2026-08-14 ### Fixed diff --git a/README.md b/README.md index 488da00..591d6cd 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ linear tasks --assignee me # by assignee: me | none | someone@x.com linear tasks --project "Rush App" # scope to one project (name or UUID) linear tasks --milestone "v1.0" # scope to one milestone (the whole deliverable) linear tasks --project "Rush App" --by-milestone # group by milestone; unmatched fall in "No milestone" -linear tasks --json | jq # machine-readable +linear tasks --all # whole team, ignoring the cwd project auto-scope +linear tasks --json | jq # machine-readable (adds "project": {id, name, auto}) linear update ANT-42 --pickup # claim (In Progress) linear update ANT-42 --comment "..." # progress note @@ -146,6 +147,7 @@ Full help: `linear --help`. The same CLI works whether you're typing or a subagent is. Driving Linear from either shouldn't require shelling out to `@linear/sdk`, hand-rolling GraphQL, or parsing HTML. - **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|`. 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). - **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. diff --git a/linear b/linear index 474b901..1798341 100755 --- a/linear +++ b/linear @@ -66,7 +66,7 @@ _LOCK_CONTENDED_ERRNOS = tuple( if e is not None ) -__version__ = "0.19.1" +__version__ = "0.20.0" # Sentinel for "flag not supplied" — distinct from None, which means an explicit # clear (e.g. `--project none`). Lets update pre-resolve a field once and pass @@ -1142,6 +1142,77 @@ def resolve_project_id(api_key: str, team_id: str, value: str, return match["id"] +def resolve_cwd_project(timeout: float = 3.0) -> tuple[str | None, str | None]: + """Best-effort: which Linear project owns the current working directory? + + The directory -> project mapping is owned by the `agents` CLI: `agents + projects` binds a repo root and its monorepo subpaths to a Linear project + (`linear.projectId` / `linear.name`). We ask it rather than re-deriving the + mapping, because `for-cwd` does a longest-match over every bound root and + subpath — a worktree, a subdir, and two projects sharing one monorepo root + all resolve correctly, none of which a basename comparison could do. + + Returns (projectId, projectName), or (None, None) when nothing claims this + directory. Fail-open by design: no `agents` on PATH, no def for this cwd, a + def with no Linear binding, or a slow/broken call all yield (None, None) and + the caller falls back to the unscoped team view. Never raises — this runs on + the hot path of the most-used command and must not brick it. + + Two bounded subprocess calls, ~0.3s each on an idle box. `for-cwd` returns + the LOCAL def name ("prix"); `list` carries the Linear binding, and several + defs may point at one project, so the id/name come from the matched def. + """ + def _agents_json(*argv: str): + try: + out = subprocess.run(["agents", *argv], capture_output=True, + text=True, timeout=timeout) + except (OSError, ValueError, subprocess.SubprocessError): + return None + if out.returncode != 0 or not out.stdout.strip(): + return None + try: + return json.loads(out.stdout) + except (ValueError, TypeError): + return None + + for_cwd = _agents_json("projects", "for-cwd", "--json") + def_name = (for_cwd or {}).get("name") if isinstance(for_cwd, dict) else None + if not def_name: + return (None, None) + + defs = _agents_json("projects", "list", "--json") + if not isinstance(defs, list): + return (None, None) + for d in defs: + if isinstance(d, dict) and d.get("name") == def_name: + lin = d.get("linear") or {} + return (lin.get("projectId"), lin.get("name")) + return (None, None) + + +def resolve_auto_project_id(api_key: str, team_id: str, value: str) -> str | None: + """Validate an auto-detected (cwd) project id/name against the team's live + projects. Returns the matching projectId, or None when it doesn't match. + + NEVER raises and never exits — the key difference from + resolve_project_id(strict=True). An explicit --project that doesn't resolve + is a user typo worth aborting on (RUSH-1496); an auto-detected value that + doesn't resolve just means the `agents projects` binding drifted (a project + renamed/recreated, or a stale recorded id) — a condition outside this CLI's + control that must fall back to the unscoped view, not brick the most-common + command. Membership is checked against the live project list, so it also + catches a stale-but-UUID-shaped id that resolve_project_id's shape-only UUID + passthrough would wave through into a filter that silently matches nothing. + """ + if not value: + return None + want = value.lower() + for p in list_team_projects(api_key, team_id): + if p.get("id") == value or (p.get("name") or "").lower() == want: + return p.get("id") + return None + + def list_project_statuses(api_key: str) -> list[dict]: """Workspace project statuses (Backlog / Planned / In Progress / ...).""" data = gql(api_key, """ @@ -2014,15 +2085,56 @@ def list_tasks(args, cfg, api_key, team_id): (`all`, cross-cycle + backlog), or the backlog only (`none`). `--assignee EMAIL|me|none` filters by who the issue is assigned to. `--project NAME|UUID` scopes to one project (strict: unknown name aborts). + With no `--project` and no `--all`, the queue auto-scopes to the project + bound to the current directory (via `agents projects`), so an agent launched + inside a project folder sees that project's work, not the whole workspace. Tasks are sorted by priority, then due date (earliest first), then id. Results are fully paginated — no silent truncation at Linear's 50-issue cap. """ + # Project scope, resolved up front because it also widens the cycle scope (a + # project view spans all cycles, not just the active one). Precedence: + # 1. explicit --project wins (existing behavior). + # 2. --all forces the whole-team view (auto-scope suppressed). + # 3. otherwise, when `agents projects` binds this cwd to a project, + # auto-scope to it — cwd behaves like an implicit --project. + # This is the fix for "an agent launched in a project subfolder lists every + # project's tasks and picks the wrong one". Opt out with `autoScope: false` + # in config. + # + # The two sources have DIFFERENT failure policies: a mistyped --project must + # abort (RUSH-1496), but a stale/malformed cwd binding — which comes from + # `agents projects`, outside this CLI's control — must fall back to the + # unscoped view, never brick the single most-common command. So the explicit + # path resolves strictly (crash on no-match) and the auto path validates + # fail-open (drift -> None -> unscoped + a one-line stderr note). + explicit_project = getattr(args, "project", None) + pid = None + auto_project_name = None + if explicit_project: + try: + pid = resolve_project_id(api_key, team_id, explicit_project, strict=True) + except LookupError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + elif not args.all and cfg.get("autoScope", True) is not False: + cwd_pid, cwd_pname = resolve_cwd_project() + if cwd_pid: + pid = resolve_auto_project_id(api_key, team_id, cwd_pid) + if pid: + auto_project_name = cwd_pname or cwd_pid + else: + print(f"linear: the current directory's project " + f"'{cwd_pname or cwd_pid}' (from agents projects) is not a " + f"live project on this team — showing the whole team. Use " + f"--project to scope explicitly.", file=sys.stderr) + filters = [f'team: {{ id: {{ eq: "{team_id}" }} }}'] # Cycle scope. active/next resolve to a concrete cycle id (and a header # name); `all` drops the cycle filter; `none`/`backlog` lists the backlog; - # any other value is treated as a cycle name or id and resolved. - scope = resolve_task_scope(args.cycle, getattr(args, "project", None), + # any other value is treated as a cycle name or id and resolved. A resolved + # project scope (explicit or auto) widens to all cycles. + scope = resolve_task_scope(args.cycle, pid, getattr(args, "milestone", None)) fragment, scope_label, cycle_meta = build_cycle_scope(api_key, team_id, scope) if fragment is None: @@ -2089,17 +2201,9 @@ def list_tasks(args, cfg, api_key, team_id): if args.label: filters.append(f'labels: {{ name: {{ eq: "{args.label}" }} }}') - # Project scope. Strict resolution: a mistyped project must abort, not - # silently drop the filter and return the whole team queue (unattended - # consumers act on whatever this lists). - project = getattr(args, "project", None) - pid = None - if project: - try: - pid = resolve_project_id(api_key, team_id, project, strict=True) - except LookupError as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) + # Project scope filter. `pid` was resolved up front: an explicit --project + # crashed on a typo, an auto-detected cwd project fell back to None on drift. + if pid: filters.append(f'project: {{ id: {{ eq: "{pid}" }} }}') # Milestone scope. Strict like --project: a mistyped milestone aborts rather @@ -2178,13 +2282,25 @@ def list_tasks(args, cfg, api_key, team_id): print(json.dumps({ "scope": scope, "cycle": cycle_meta, + # The effective project scope, so machine consumers can tell an + # auto-scoped queue from the whole workspace. Null when unscoped. + "project": ({"id": pid, "name": auto_project_name or explicit_project, + "auto": bool(auto_project_name)} if pid else None), "count": len(nodes), "issues": nodes, }, indent=2)) return + # When the cwd project auto-scoped the queue, say so — otherwise a short or + # empty list reads as "no work" when it just means "none in THIS project". + if auto_project_name: + print(f"Scope: {auto_project_name} (auto-detected from this directory; " + f"--all for the whole team, --project X to override)") + if not nodes: - print(f"No matching tasks in {scope_label}.") + hint = (f" (scoped to {auto_project_name}; try --all, or --cycle all " + f"for the backlog)") if auto_project_name else "" + print(f"No matching tasks in {scope_label}.{hint}") return header_note = "" @@ -2197,7 +2313,7 @@ def list_tasks(args, cfg, api_key, team_id): # Group by milestone when asked (--by-milestone) or when scoped to a project # (the natural project view). The milestone name is already on each issue via # ISSUE_FIELDS, so this is a local group-by with no extra API calls. - if getattr(args, "by_milestone", False) or bool(project): + if getattr(args, "by_milestone", False) or bool(pid): print_by_milestone(nodes) else: for n in nodes: @@ -2205,13 +2321,40 @@ def list_tasks(args, cfg, api_key, team_id): def show_board(args, api_key, team_id, cfg): + # Full parity with list_tasks: an explicit --project resolves strictly and + # aborts on a typo; --all shows every project; otherwise the board + # auto-scopes to the cwd's project, validated fail-open so a stale/malformed + # binding falls back to the whole team instead of rendering a + # misleadingly-empty board. Resolved BEFORE the cycle scope, because — like + # list_tasks — a project scope widens the cycle to the whole deliverable. + explicit_project = getattr(args, "project", None) + auto_project_name = None + pid = None + if explicit_project: + try: + pid = resolve_project_id(api_key, team_id, explicit_project, strict=True) + except LookupError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + elif not getattr(args, "all", False) and cfg.get("autoScope", True) is not False: + cwd_pid, cwd_pname = resolve_cwd_project() + if cwd_pid: + pid = resolve_auto_project_id(api_key, team_id, cwd_pid) + if pid: + auto_project_name = cwd_pname or cwd_pid + else: + print(f"linear: the current directory's project " + f"'{cwd_pname or cwd_pid}' (from agents projects) is not a " + f"live project on this team — showing the whole team. Use " + f"--project to scope explicitly.", file=sys.stderr) + # Same cycle scoping as list_tasks (active/next/all/none/name/id), so the # board no longer hits the non-existent team.nextCycle field, and it - # paginates instead of silently capping at 50 open issues. - # --cycle defaults to None at the parser (so list_tasks can widen it); the - # board has no project/milestone scope to widen for, so None means active — - # normalize here so the --json "scope" field stays "active", not null. - scope = args.cycle or "active" + # paginates instead of silently capping at 50 open issues. --cycle defaults + # to None at the parser; a project scope widens to all cycles (the whole + # deliverable), otherwise the board's default is the active cycle — and + # normalizing None here keeps the --json "scope" field a string, not null. + scope = args.cycle or ("all" if pid else "active") fragment, scope_label, cycle_meta = build_cycle_scope(api_key, team_id, scope) if fragment is None: print(f"Could not resolve --cycle '{scope}'.") @@ -2223,6 +2366,8 @@ def show_board(args, api_key, team_id, cfg): ] if fragment: filters.append(fragment) + if pid: + filters.append(f'project: {{ id: {{ eq: "{pid}" }} }}') nodes = paginate_issues(api_key, ", ".join(filters)) if nodes is None: @@ -2234,13 +2379,20 @@ def show_board(args, api_key, team_id, cfg): print(json.dumps({ "scope": scope, "cycle": cycle_meta, + "project": ({"id": pid, "name": auto_project_name or explicit_project, + "auto": bool(auto_project_name)} if pid else None), "count": len(nodes), "issues": nodes, }, indent=2)) return + if auto_project_name: + print(f"Scope: {auto_project_name} (auto-detected from this directory; " + f"--all for the whole team, --project X to override)") + if not nodes: - print(f"No open tasks in {cycle_name}.") + hint = f" (scoped to {auto_project_name}; --all for the whole team)" if auto_project_name else "" + print(f"No open tasks in {cycle_name}.{hint}") return # Group by the native delegate. An issue has at most one delegate, so a board @@ -4571,10 +4723,10 @@ def main(): p_tasks = sub.add_parser("tasks", help="List tasks, view details, or show team board") p_tasks.add_argument("identifier", nargs="?", help="Issue identifier for detail view (e.g. ANT-42)") p_tasks.add_argument("--agent", help="Filter to the agent this issue is delegated to (e.g. --agent claude). See: linear agents") - p_tasks.add_argument("--all", action="store_true", help="Ignore default agent filter, show all") + p_tasks.add_argument("--all", action="store_true", help="Ignore default agent filter AND cwd project auto-scope; show the whole team") p_tasks.add_argument("--board", action="store_true", help="Team board grouped by agent") p_tasks.add_argument("--label", help="Filter by any label") - p_tasks.add_argument("--project", help="Filter by project (name or UUID)") + p_tasks.add_argument("--project", help="Filter by project (name or UUID). Defaults to the cwd's project when `agents projects` binds one; --all disables that.") p_tasks.add_argument("--milestone", help="Filter by milestone (name or UUID). Pair with " "--project to disambiguate a name across projects.") diff --git a/skill.md b/skill.md index 0b8f3db..6fbc2fd 100644 --- a/skill.md +++ b/skill.md @@ -10,8 +10,9 @@ You have access to the `linear` CLI — a Linear task manager built for AI agent ## Core workflow ``` -linear tasks # your assigned queue in the active cycle -linear tasks --board # whole team board, grouped by delegate +linear tasks # your queue; auto-scoped to the cwd's project when one is bound +linear tasks --all # ignore the cwd project auto-scope; whole team +linear tasks --board # team board (also cwd-scoped), grouped by delegate linear tasks ANT-42 # detail view for one issue linear update ANT-42 --pickup # move to In Progress linear update ANT-42 --comment "..." # drop a progress note diff --git a/test_linear.py b/test_linear.py index 65d0768..863b461 100644 --- a/test_linear.py +++ b/test_linear.py @@ -8,6 +8,7 @@ import errno import importlib.util import io +import json import os import sys import tempfile @@ -586,26 +587,73 @@ def fake_gql(_api_key, query, _variables): self.assertNotIn("$pid: String", captured["query"]) +def _run_board(nodes, cfg=None, cwd_project=(None, None), **overrides): + """Drive show_board against a fixed page. Stubs the two network edges plus + resolve_cwd_project so the run is hermetic (no real `agents` shell-out).""" + args = types.SimpleNamespace(cycle=None, json=True, board=True, + all=False, project=None) + for k, v in overrides.items(): + setattr(args, k, v) + cfg = {} if cfg is None else cfg + captured = {} + saved = {n: getattr(linear_cli, n) + for n in ("build_cycle_scope", "paginate_issues", "resolve_cwd_project", + "list_team_projects")} + linear_cli.build_cycle_scope = lambda a, t, s: ( + 'cycle: { id: { eq: "x" } }', "Active cycle", {"id": "x"}) + + def _pag(_api, filter_str): + captured["filter"] = filter_str + return list(nodes) + linear_cli.paginate_issues = _pag + linear_cli.resolve_cwd_project = lambda *a, **k: cwd_project + linear_cli.list_team_projects = lambda a, t: [ + {"id": _PRIX_UUID, "name": "Prix"}, + {"id": _OTHER_UUID, "name": "Other"}, + ] + buf = io.StringIO() + try: + with contextlib.redirect_stdout(buf): + linear_cli.show_board(args, "api-key", "team-id", cfg) + finally: + for n, fn in saved.items(): + setattr(linear_cli, n, fn) + out = json.loads(buf.getvalue()) + out["_last_filter"] = captured.get("filter") + return out + + class BoardJsonScopeTest(unittest.TestCase): def test_default_board_scope_is_active_not_null(self): # Regression guard: --cycle defaults to None at the parser (so list_tasks # can widen); the board must normalize None -> "active" so # `linear tasks --board --json` never emits "scope": null. - args = types.SimpleNamespace(cycle=None, json=True, board=True) - original_bcs = linear_cli.build_cycle_scope - original_pag = linear_cli.paginate_issues - linear_cli.build_cycle_scope = lambda a, t, s: ( - 'cycle: { id: { eq: "x" } }', "Active cycle", {"id": "x"}) - linear_cli.paginate_issues = lambda a, f: [] - buf = io.StringIO() - try: - with contextlib.redirect_stdout(buf): - linear_cli.show_board(args, "api-key", "team-id", {}) - finally: - linear_cli.build_cycle_scope = original_bcs - linear_cli.paginate_issues = original_pag - import json as _json - self.assertEqual(_json.loads(buf.getvalue())["scope"], "active") + out = _run_board([]) + self.assertEqual(out["scope"], "active") + + def test_board_auto_scopes_to_cwd_project(self): + out = _run_board([], cwd_project=(_PRIX_UUID, "Prix")) + self.assertEqual(out["project"], {"id": _PRIX_UUID, "name": "Prix", "auto": True}) + self.assertIn(f'project: {{ id: {{ eq: "{_PRIX_UUID}" }} }}', out["_last_filter"]) + # Parity with list_tasks: a project scope widens the board to all cycles. + self.assertEqual(out["scope"], "all") + + def test_board_unscoped_stays_on_active_cycle(self): + out = _run_board([], cwd_project=(None, None)) + self.assertIsNone(out["project"]) + self.assertEqual(out["scope"], "active") + + def test_board_all_disables_auto_scope(self): + out = _run_board([], cwd_project=(_PRIX_UUID, "Prix"), all=True) + self.assertIsNone(out["project"]) + self.assertNotIn("project:", out["_last_filter"]) + + def test_board_drifted_binding_falls_back_to_unscoped(self): + # Parity with list_tasks: a cwd id that is not a live project must leave + # the board unscoped instead of rendering a misleadingly-empty board. + out = _run_board([], cwd_project=("99999999-9999-9999-9999-999999999999", "Ghost")) + self.assertIsNone(out["project"]) + self.assertNotIn("project:", out["_last_filter"]) def _issue(ident, delegate=None, labels=(), priority=2, state="Todo"): @@ -638,17 +686,34 @@ class _ListTasksHarness: ROSTER = [{"id": "id-claude", "name": "Claude"}, {"id": "id-codex", "name": "Codex"}] - def __init__(self, nodes): + def __init__(self, nodes, cwd_project=(None, None)): self.nodes = nodes + # What resolve_cwd_project() returns for this run. Default (None, None) + # = no cwd binding, so every pre-existing test keeps its unscoped + # behavior and never shells out to a real `agents` binary. + self.cwd_project = cwd_project + self.last_filter = None self._saved = {} + def _capture_paginate(self, _api, filter_str): + self.last_filter = filter_str + return list(self.nodes) + def __enter__(self): - for name in ("build_cycle_scope", "paginate_issues", "get_agents"): + for name in ("build_cycle_scope", "paginate_issues", "get_agents", + "resolve_cwd_project", "list_team_projects"): self._saved[name] = getattr(linear_cli, name) linear_cli.build_cycle_scope = lambda a, t, s: ( 'cycle: { id: { eq: "x" } }', "Cycle 23", {"id": "x"}) - linear_cli.paginate_issues = lambda a, f: list(self.nodes) + linear_cli.paginate_issues = self._capture_paginate linear_cli.get_agents = lambda a, c, force=False: list(self.ROSTER) + linear_cli.resolve_cwd_project = lambda *a, **k: self.cwd_project + # The live project list resolve_auto_project_id validates the cwd id + # against. _PRIX_UUID is a member; an unknown id is treated as drift. + linear_cli.list_team_projects = lambda a, t: [ + {"id": _PRIX_UUID, "name": "Prix"}, + {"id": _OTHER_UUID, "name": "Other"}, + ] return self def __exit__(self, *exc): @@ -668,15 +733,177 @@ def _list_args(**overrides): return args -def _run_list(nodes, cfg=None, **overrides): +def _run_list(nodes, cfg=None, cwd_project=(None, None), **overrides): args = _list_args(**overrides) cfg = {"agent": "claude"} if cfg is None else cfg buf = io.StringIO() - with _ListTasksHarness(nodes): + with _ListTasksHarness(nodes, cwd_project=cwd_project) as h: with contextlib.redirect_stdout(buf): linear_cli.list_tasks(args, cfg, "api-key", "team-id") import json as _json - return _json.loads(buf.getvalue()) + out = _json.loads(buf.getvalue()) + out["_last_filter"] = h.last_filter + return out + + +_PRIX_UUID = "84849630-061b-492f-9043-ae8af40c60b1" +_OTHER_UUID = "11111111-1111-1111-1111-111111111111" + + +class ResolveCwdProjectTest(unittest.TestCase): + """resolve_cwd_project shells out to `agents projects`; fail-open on anything.""" + + @staticmethod + def _fake_run(outputs): + """outputs: dict mapping the joined argv tail to a (returncode, stdout). + A missing key or a value of None simulates the process raising.""" + class _Res: + def __init__(self, rc, out): + self.returncode = rc + self.stdout = out + + def run(argv, capture_output=True, text=True, timeout=None): + key = " ".join(argv[1:]) # drop the leading "agents" + val = outputs.get(key) + if val is None: + raise FileNotFoundError("agents") + rc, out = val + return _Res(rc, out) + return run + + def _with_run(self, run): + original = linear_cli.subprocess.run + linear_cli.subprocess.run = run + self.addCleanup(lambda: setattr(linear_cli.subprocess, "run", original)) + + def test_resolves_cwd_to_bound_linear_project(self): + self._with_run(self._fake_run({ + "projects for-cwd --json": (0, json.dumps({"name": "prix"})), + "projects list --json": (0, json.dumps([ + {"name": "other", "linear": {"projectId": _OTHER_UUID, "name": "Other"}}, + {"name": "prix", "linear": {"projectId": _PRIX_UUID, "name": "Prix"}}, + ])), + })) + self.assertEqual(linear_cli.resolve_cwd_project(), (_PRIX_UUID, "Prix")) + + def test_unbound_cwd_is_none(self): + self._with_run(self._fake_run({ + "projects for-cwd --json": (0, json.dumps({"name": None})), + })) + self.assertEqual(linear_cli.resolve_cwd_project(), (None, None)) + + def test_missing_agents_binary_is_none(self): + # subprocess.run raises FileNotFoundError -> fail open, never propagate. + self._with_run(self._fake_run({})) + self.assertEqual(linear_cli.resolve_cwd_project(), (None, None)) + + def test_nonzero_exit_is_none(self): + self._with_run(self._fake_run({ + "projects for-cwd --json": (1, ""), + })) + self.assertEqual(linear_cli.resolve_cwd_project(), (None, None)) + + def test_def_without_linear_binding_is_none(self): + self._with_run(self._fake_run({ + "projects for-cwd --json": (0, json.dumps({"name": "prix"})), + "projects list --json": (0, json.dumps([{"name": "prix"}])), + })) + self.assertEqual(linear_cli.resolve_cwd_project(), (None, None)) + + def test_timeout_is_none(self): + def run(argv, **k): + raise linear_cli.subprocess.TimeoutExpired(cmd="agents", timeout=3) + self._with_run(run) + self.assertEqual(linear_cli.resolve_cwd_project(), (None, None)) + + +class ResolveAutoProjectIdTest(unittest.TestCase): + """Auto-detected cwd project ids are validated fail-open (never raise/exit).""" + + def _with_projects(self, projects): + original = linear_cli.list_team_projects + linear_cli.list_team_projects = lambda a, t: projects + self.addCleanup(lambda: setattr(linear_cli, "list_team_projects", original)) + + def test_matches_by_id(self): + self._with_projects([{"id": _PRIX_UUID, "name": "Prix"}]) + self.assertEqual(linear_cli.resolve_auto_project_id("k", "t", _PRIX_UUID), _PRIX_UUID) + + def test_matches_by_name_case_insensitive(self): + self._with_projects([{"id": _PRIX_UUID, "name": "Prix"}]) + self.assertEqual(linear_cli.resolve_auto_project_id("k", "t", "prix"), _PRIX_UUID) + + def test_unknown_id_returns_none_not_raise(self): + self._with_projects([{"id": _PRIX_UUID, "name": "Prix"}]) + self.assertIsNone(linear_cli.resolve_auto_project_id("k", "t", "no-such-uuid")) + + def test_empty_value_returns_none(self): + # No API call needed for an empty value. + self.assertIsNone(linear_cli.resolve_auto_project_id("k", "t", "")) + + def test_empty_project_list_returns_none(self): + self._with_projects([]) + self.assertIsNone(linear_cli.resolve_auto_project_id("k", "t", _PRIX_UUID)) + + +class CwdAutoScopeTest(unittest.TestCase): + """`linear tasks` in a project-bound folder auto-scopes to that project.""" + + def _nodes(self): + return [_issue("R-1", delegate="Claude"), _issue("R-2", delegate="Codex"), + _issue("R-3")] + + def test_no_binding_leaves_queue_unscoped(self): + out = _run_list(self._nodes(), cwd_project=(None, None)) + self.assertIsNone(out["project"]) + self.assertEqual(out["scope"], "active") + self.assertNotIn("project:", out["_last_filter"]) + + def test_binding_auto_scopes_and_widens_to_all_cycles(self): + out = _run_list(self._nodes(), cwd_project=(_PRIX_UUID, "Prix")) + self.assertEqual(out["project"], {"id": _PRIX_UUID, "name": "Prix", "auto": True}) + # A project scope widens the cycle to the whole deliverable. + self.assertEqual(out["scope"], "all") + self.assertIn(f'project: {{ id: {{ eq: "{_PRIX_UUID}" }} }}', out["_last_filter"]) + + def test_all_disables_auto_scope(self): + out = _run_list(self._nodes(), cwd_project=(_PRIX_UUID, "Prix"), all=True) + self.assertIsNone(out["project"]) + self.assertNotIn("project:", out["_last_filter"]) + + def test_explicit_project_overrides_cwd(self): + # Explicit --project (a UUID, passed through without a lookup) wins; the + # cwd binding is ignored and the result is not marked auto. + out = _run_list(self._nodes(), cwd_project=(_PRIX_UUID, "Prix"), + project=_OTHER_UUID) + self.assertEqual(out["project"], {"id": _OTHER_UUID, "name": _OTHER_UUID, "auto": False}) + self.assertIn(f'project: {{ id: {{ eq: "{_OTHER_UUID}" }} }}', out["_last_filter"]) + self.assertNotIn(_PRIX_UUID, out["_last_filter"]) + + def test_autoscope_false_config_opts_out(self): + out = _run_list(self._nodes(), cfg={"agent": "claude", "autoScope": False}, + cwd_project=(_PRIX_UUID, "Prix")) + self.assertIsNone(out["project"]) + self.assertNotIn("project:", out["_last_filter"]) + + def test_drifted_cwd_binding_falls_back_to_unscoped(self): + # `agents projects` resolves the cwd to a project id that is NOT a live + # project on this team (renamed/recreated project, stale recorded id). + # The auto path must fall back to the whole-team view — NOT sys.exit(1) + # the way an explicit --project typo does. (Regression: the BLOCKER.) + ghost = "99999999-9999-9999-9999-999999999999" + out = _run_list(self._nodes(), cwd_project=(ghost, "Ghost")) + self.assertIsNone(out["project"]) + self.assertEqual(out["scope"], "active") + self.assertNotIn("project:", out["_last_filter"]) + + def test_drifted_non_uuid_binding_does_not_crash(self): + # A malformed (non-UUID) recorded id would hit resolve_project_id's name + # path and, under strict resolution, exit 1. The fail-open auto path must + # swallow it and show the whole team instead. + out = _run_list(self._nodes(), cwd_project=("some-slug", "Slug")) + self.assertIsNone(out["project"]) + self.assertNotIn("project:", out["_last_filter"]) class DelegateOwnershipTest(unittest.TestCase):