import json import os import re import time from .adapters import ADAPTERS # type: ignore[reportMissingImports] from .detection import resolve_provider # type: ignore[reportMissingImports] from .errors import TrackerError # type: ignore[reportMissingImports] from .models import CompletedCommand, Envelope, ResourceRef, RetryPolicy # type: ignore[reportMissingImports] from .runner import SubprocessRunner # type: ignore[reportMissingImports] TRANSIENT_MARKERS = ("timeout", "timed out", "connection", "network", "temporarily", "try again", "rate limit", "429", "502", "503", "504") AUTH_MARKERS = ("not logged", "authentication", "unauthorized", "forbidden", "login", "token") NOT_FOUND_MARKERS = ("not found", "does not exist", "unknown issue", "unknown pull") class Tracker: """Provider-neutral facade. Every method returns the CLI-compatible envelope dict.""" def __init__(self, provider=None, *, runner=None, repo=None, cwd=None, retry=None, env=None, external_associations=None): self.runner = runner or SubprocessRunner() self.repo = repo self.cwd = cwd or os.getcwd() self.env = env self.external_associations = external_associations self.retry = retry or RetryPolicy() remote = None if provider is None and not (env or os.environ).get("TRACKER_PROVIDER"): remote = self._discover_remote() self.provider = resolve_provider(provider, env=env, remote=remote) self.adapter = ADAPTERS[self.provider](repo=repo) def _discover_remote(self): try: result = self._run_runner(["git", "remote", "get-url", "origin"], retries=1) except TrackerError: return None return result.stdout.strip() or None def _run_runner(self, argv, *, retries=None) -> CompletedCommand: attempts = retries or self.retry.attempts last = None for attempt in range(attempts): try: if hasattr(self.runner, "run"): response = self.runner.run(argv, cwd=self.cwd) else: response = self.runner(argv) except (OSError, TimeoutError) as exc: last = CompletedCommand(stderr=str(exc), returncode=1) else: if isinstance(response, CompletedCommand): last = response elif isinstance(response, tuple): last = CompletedCommand(*response) elif isinstance(response, dict): last = CompletedCommand(**response) else: raise TypeError("runner must return CompletedCommand, tuple, or dict") if last.returncode == 0: return last if not self._is_transient(last.stderr) or attempt == attempts - 1: return last if self.retry.delay: time.sleep(self.retry.delay) return last or CompletedCommand(stderr="provider runner returned no result", returncode=1) @staticmethod def _number(value): try: number = int(value) except (TypeError, ValueError) as error: raise TrackerError("invalid_input", f"invalid resource number: {value}") from error if number < 1: raise TrackerError("invalid_input", "resource number must be positive") return number @staticmethod def _is_transient(message): text = (message or "").lower() return any(marker in text for marker in TRANSIENT_MARKERS) def _failure(self, operation, message, *, returncode=1, attempts=None, uncertain=False): text = (message or "provider command failed").strip() lower = text.lower() if uncertain: code, retryable = "uncertain_outcome", False elif any(marker in lower for marker in AUTH_MARKERS): code, retryable = "auth_required", False elif "no such file" in lower or ("executable" in lower and "not found" in lower): code, retryable = "provider_cli_missing", False elif any(marker in lower for marker in NOT_FOUND_MARKERS): code, retryable = "not_found", False elif "rate limit" in lower or "429" in lower: code, retryable = "rate_limited", True elif self._is_transient(lower): code, retryable = "transient_failure", True else: code, retryable = "provider_error", False return TrackerError( code, text, retryable=retryable, provider=self.provider, operation=operation, details={"returncode": returncode, **({"attempts": attempts} if attempts else {})}, ) def _run(self, operation, argv, *, mutating=False, uncertain=False) -> CompletedCommand: result = self._run_runner(argv) if result.returncode: error = self._failure( operation, result.stderr or result.stdout, returncode=result.returncode, attempts=self.retry.attempts, uncertain=uncertain and self._is_transient(result.stderr), ) raise error return result def _json(self, operation, argv, *, mutating=False, uncertain=False): result = self._run(operation, argv, mutating=mutating, uncertain=uncertain) text = result.stdout.strip() if not text: return {} try: return json.loads(text) except (TypeError, ValueError): return {"output": text} def _safe(self, operation, function): try: result, details = function() return Envelope(True, self.provider, operation, result, details).to_dict() except TrackerError as error: if error.provider is None: error.provider = self.provider if error.operation is None: error.operation = operation return Envelope(False, self.provider, operation, error=error.to_dict()).to_dict() except (ValueError, TypeError) as error: failure = TrackerError("invalid_input", str(error), provider=self.provider, operation=operation) return Envelope(False, self.provider, operation, error=failure.to_dict()).to_dict() def _call_json(self, operation, command, *, kind=None, mutating=False, uncertain=False): data = self._json(operation, command, mutating=mutating, uncertain=uncertain) if kind: if isinstance(data, list): data = [self.adapter.normalize(item, kind) for item in data] elif isinstance(data, dict) and isinstance(data.get("items"), list): data = {**data, "items": [self.adapter.normalize(item, kind) for item in data["items"]]} else: data = self.adapter.normalize(data, kind) return data @staticmethod def _as_list(value): if isinstance(value, list): return value if isinstance(value, dict): for key in ("items", "labels", "data", "results"): if isinstance(value.get(key), list): return value[key] return [] @staticmethod def _label_names(labels): return {item.get("name") if isinstance(item, dict) else item for item in labels} def _ensure_label_impl(self, name, color="ededed", description=None): labels = self._call_json("label.ensure", self.adapter.command("label.list")) found = next((label for label in self._as_list(labels) if (label.get("name") if isinstance(label, dict) else label) == name), None) if found is not None: return found, {"created": False} created = self._call_json( "label.ensure", self.adapter.command("label.create", name=name, color=color, description=description), mutating=True, ) return created or {"name": name, "color": color, "description": description}, {"created": True} def ensure_label(self, name, *, color="ededed", description=None): return self._safe("label.ensure", lambda: self._ensure_label_impl(name, color, description)) def _create(self, kind, title, body="", labels=(), assignees=(), head=None, base=None): for label in labels: self._ensure_label_impl(label) command_args = {"title": title, "body": body, "labels": list(labels), "assignees": list(assignees)} if kind == "pr": command_args.update(head=head, base=base) data = self._call_json( f"{kind}.create", self.adapter.command(f"{kind}.create", **command_args), kind=kind, mutating=True, ) return data, {} def create_issue(self, title, *, body="", labels=(), assignees=()): return self._safe("issue.create", lambda: self._create("issue", title, body, labels, assignees)) def create_pr(self, title, *, body="", head=None, base=None, labels=(), assignees=()): return self._safe("pr.create", lambda: self._create("pr", title, body, labels, assignees, head, base)) def _get(self, kind, number, comments=True): ref = ResourceRef(kind, self._number(number)) data = self._call_json(f"{kind}.get", self.adapter.command(f"{kind}.get", number=ref.number, comments=comments), kind=kind) return data, {} def get_issue(self, number, *, comments=True): return self._safe("issue.get", lambda: self._get("issue", number, comments)) def get_pr(self, number, *, comments=True, diff=False): def operation(): data, details = self._get("pr", number, comments) if diff: if not isinstance(data, dict): data = {"resource": data} data["diff"] = self._run("pr.diff", self.adapter.command("pr.diff", number=self._number(number))).stdout details["included_diff"] = True return data, details return self._safe("pr.get", operation) def _list(self, kind, state="open", labels=(), limit=100): command_args = {"state": state, "limit": limit} if kind == "issue": command_args["labels"] = list(labels) data = self._call_json(f"{kind}.list", self.adapter.command(f"{kind}.list", **command_args), kind=kind) return data, {} def list_issues(self, *, state="open", labels=(), limit=100): return self._safe("issue.list", lambda: self._list("issue", state, labels, limit)) def list_prs(self, *, state="open", limit=100, external_only=False): def operation(): data, details = self._list("pr", state, (), limit) if not external_only: return data, details resources = self._as_list(data) external = [] for resource in resources: association = resource.get("author_association") if isinstance(resource, dict) else None if association is None: raise TrackerError( "unsupported_capability", f"{self.provider} did not provide author membership metadata", provider=self.provider, operation="pr.list", details={"capability": "author_membership"}, ) values = self.external_associations or {"owner", "member", "collaborator"} if str(association).lower() not in {str(value).lower() for value in values}: external.append(resource) return external, {**details, "external_only": True} return self._safe("pr.list", operation) def _edit(self, kind, number, **kwargs): data = self._call_json(f"{kind}.edit", self.adapter.command(f"{kind}.edit", number=self._number(number), **kwargs), kind=kind, mutating=True) return data, {} def edit_issue(self, number, *, title=None, body=None): return self._safe("issue.edit", lambda: self._edit("issue", number, title=title, body=body)) def edit_pr(self, number, *, title=None, body=None): return self._safe("pr.edit", lambda: self._edit("pr", number, title=title, body=body)) def comment(self, kind, number, body): def operation(): result = self._call_json(f"{kind}.comment", self.adapter.command(f"{kind}.comment", number=self._number(number), body=body), mutating=True, uncertain=True) return result, {} return self._safe(f"{kind}.comment", operation) def add_label(self, kind, number, name, *, color="ededed", description=None): def operation(): _, ensure_details = self._ensure_label_impl(name, color, description) result = self._call_json( f"{kind}.label.add", self.adapter.command(f"{kind}.edit", number=self._number(number), add_label=name), kind=kind, mutating=True, ) return result, {"label": name, "ensured": ensure_details} return self._safe(f"{kind}.label.add", operation) def remove_label(self, kind, number, name): return self._safe( f"{kind}.label.remove", lambda: (self._edit("issue" if kind == "issue" else "pr", number, remove_label=name)[0], {}), ) def assign(self, kind, number, user): return self._safe( f"{kind}.assign", lambda: (self._edit(kind, number, assignee=user)[0], {"assignee": user}), ) def close(self, kind, number, *, explanation=None): def operation(): steps = [] if explanation: self._call_json(f"{kind}.comment", self.adapter.command(f"{kind}.comment", number=self._number(number), body=explanation), mutating=True, uncertain=True) steps.append("comment") self._call_json(f"{kind}.close", self.adapter.command(f"{kind}.close", number=self._number(number)), kind=kind, mutating=True) steps.append("close") return {"number": self._number(number), "closed": True}, {"completed": steps} return self._safe(f"{kind}.close", operation) def diff(self, number): return self._safe("pr.diff", lambda: ({"diff": self._run("pr.diff", self.adapter.command("pr.diff", number=self._number(number))).stdout}, {})) def resolve_reference(self, number): """Resolve a shared issue/PR number explicitly; GitLab keeps its spaces separate.""" def operation(): matches = [] for kind in ("issue", "pr"): result = self._run_runner(self.adapter.command(f"{kind}.get", number=self._number(number), comments=False)) if result.returncode == 0: matches.append(self.adapter.normalize(self.adapter.json_value(result.stdout), kind)) if len(matches) != 1: code = "ambiguous_reference" if len(matches) > 1 else "not_found" raise TrackerError(code, f"reference #{number} did not resolve to exactly one resource", provider=self.provider, details={"matches": matches}) return matches[0], {"matches": [matches[0]["kind"]]} return self._safe("reference.resolve", operation) def create_map(self, title, *, body="", labels=()): map_labels = tuple(dict.fromkeys(["wayfinder:map", *labels])) return self._safe("map.create", lambda: self._create("issue", title, body, map_labels, ()) ) @staticmethod def _result_number(result): if isinstance(result, dict): if result.get("number"): return result["number"] match = re.search(r"/(?:issues|pulls)/(\d+)", str(result.get("output", ""))) if match: try: return int(match.group(1)) except (TypeError, ValueError): return None return None def _native_child_link(self, map_number, child_number): if self.provider == "github": command = ["gh", "api", "--method", "POST", f"repos/{{owner}}/{{repo}}/issues/{map_number}/sub_issues", "-F", f"sub_issue_id={child_number}"] elif self.provider == "gitea": command = ["tea", "api", "--method", "POST", f"/repos/{{owner}}/{{repo}}/issues/{map_number}/sub-issues", "-F", f"child_issue_id={child_number}"] else: return False try: self._run("child.link", command, mutating=True) except TrackerError: return False return True def _append_map_child(self, map_number, child_number, title): if child_number is None: return False try: map_data, _ = self._get("issue", map_number) body = map_data.get("body", "") if isinstance(map_data, dict) else "" line = f"- [ ] #{child_number} {title}" if line not in body: body = f"{body.rstrip()}\n\n{line}".lstrip() self._edit("issue", map_number, body=body) return True except TrackerError: return False def create_child(self, map_number, title, *, wayfinder_type="task", body="", labels=()): def operation(): map_number_value = self._number(map_number) child_body = f"Part of #{map_number_value}\n\n{body}".rstrip() child_labels = tuple(dict.fromkeys([f"wayfinder:{wayfinder_type}", *labels])) result, details = self._create("issue", title, child_body, child_labels, ()) child_number = self._result_number(result) native = self._native_child_link(map_number_value, child_number) fallback = self._append_map_child(map_number_value, child_number, title) details.update({"relationship": "native" if native else "fallback_task_list", "map_updated": fallback}) return result, details return self._safe("child.create", operation) def capabilities(self): native = { "child_relationships": self.provider == "github", "blocking_dependencies": self.provider in {"github", "gitea"}, "diff": True, "author_membership": self.provider == "github", } return Envelope(True, self.provider, "capabilities", native).to_dict() def list_external_prs(self, *, state="open", limit=100): return self.list_prs(state=state, limit=limit, external_only=True) def _special(self, operation, action, child, blocker): if self.provider == "github": command = ["gh", "api", "--method", "POST", f"repos/{{owner}}/{{repo}}/issues/{child}/dependencies/blocked_by", "-F", f"issue_id={blocker}"] elif self.provider == "gitlab": command = ["glab", "issue", "note", str(child), "--message", f"/blocked_by #{blocker}"] else: command = ["tea", "api", "--method", "POST", f"/repos/{{owner}}/{{repo}}/issues/{child}/dependencies", "-F", f"index={blocker}"] return self._json(operation, command, mutating=True) def add_dependency(self, child, blocker): return self._safe("dependency.add", lambda: (self._special("dependency.add", "add", self._number(child), self._number(blocker)), {"fallback": self.provider == "gitlab"})) @staticmethod def _refs(body): numbers = [] for value in re.findall(r"(?:Part of|\[[ xX]\].*?)?\s*#(\d+)", body or ""): try: numbers.append(int(value)) except (TypeError, ValueError): continue return numbers def frontier(self, map_number): def operation(): map_data, _ = self._get("issue", self._number(map_number)) body = map_data.get("body", "") if isinstance(map_data, dict) else "" candidates = [] map_order = self._refs(body) order = {str(number): index for index, number in enumerate(map_order)} for number in map_order: child_value = self._get("issue", self._number(number))[0] if not isinstance(child_value, dict): continue child = child_value if child.get("state") == "open" and not child.get("assignees") and not re.search(r"Blocked by:\s*#", child.get("body", ""), re.I): candidates.append(child) candidates.sort(key=lambda item: order.get(str(item.get("number", "")), 999999)) return candidates, {"map": self._number(map_number), "deterministic": True} return self._safe("frontier.query", operation) def _current_user(self): if self.provider == "github": result = self._json("auth.current_user", ["gh", "api", "user", "--jq", ".login"]) elif self.provider == "gitlab": result = self._json("auth.current_user", ["glab", "api", "user"]) else: result = self._json("auth.current_user", ["tea", "api", "/user"]) if isinstance(result, str): return result if isinstance(result, dict): return result.get("login", result.get("username", result.get("name", result.get("output")))) return None def claim(self, kind, number, *, user=None): def operation(): owner = user or self._current_user() if not owner: raise TrackerError("current_user_unavailable", "provider did not return the current user", provider=self.provider) result = self._edit(kind, number, assignee=owner)[0] return result, {"assignee": owner} return self._safe(f"{kind}.claim", operation) def resolve(self, kind, number, answer, *, map_number=None): def operation(): completed = [] try: self._call_json(f"{kind}.comment", self.adapter.command(f"{kind}.comment", number=self._number(number), body=answer), mutating=True, uncertain=True) completed.append("comment") self._call_json(f"{kind}.close", self.adapter.command(f"{kind}.close", number=self._number(number)), mutating=True) completed.append("close") if map_number: pointer = f"Resolved #{self._number(number)}: {answer.splitlines()[0][:200]}" self._call_json("map.pointer", self.adapter.command("issue.comment", number=self._number(map_number), body=pointer), mutating=True, uncertain=True) completed.append("map_pointer") except TrackerError as error: raise TrackerError("partial_failure", str(error), retryable=False, provider=self.provider, details={"completed": completed, "recovery": "repeat only incomplete steps"}) return {"number": self._number(number), "resolved": True}, {"completed": completed} return self._safe(f"{kind}.resolve", operation)