feat: add provider-neutral tracker automation

This commit is contained in:
2026-08-17 22:36:05 -04:00
parent f65a6ec812
commit dead6ca2bb
28 changed files with 1557 additions and 17 deletions
+30
View File
@@ -0,0 +1,30 @@
# Tracker automation
`tracker` is the provider-neutral execution seam for issue-tracker skills. It emits one JSON envelope on stdout and delegates authentication and repository work to `gh`, `glab`, or `tea`.
## Use
```sh
python -m tracker --provider gitea issue get 16
TRACKER_PROVIDER=github tracker issue list --state open --label ready-for-agent
tracker pr get 42 --diff
```
Provider precedence is `--provider`, `TRACKER_PROVIDER`, then the `origin` Git remote. Explicit resource commands (`issue` and `pr`) avoid shared-number ambiguity. Use `resolve-reference` only when intentional resolution of a bare number is required.
Stable failures are returned as:
```json
{"ok":false,"provider":"gitea","operation":"issue.get","error":{"code":"auth_required","message":"...","retryable":false,"provider":"gitea","operation":"issue.get","details":{}}}
```
The Python API is the same seam as the CLI:
```python
from tracker import Tracker
tracker = Tracker(provider="gitea")
result = tracker.add_label("issue", 16, "needs-review")
```
Inject `RecordingRunner` or another object with `run(argv, **kwargs)` for deterministic contract tests. Provider-specific capability gaps are explicit in `error.code` or `details`; credentials are never accepted or stored by this package.
+21
View File
@@ -0,0 +1,21 @@
"""Provider-neutral issue tracker automation library."""
# pi-lens-ignore: reportMissingImports
from .detection import resolve_provider # type: ignore[reportMissingImports]
# pi-lens-ignore: reportMissingImports
from .errors import TrackerError # type: ignore[reportMissingImports]
from .models import CompletedCommand, Envelope, ResourceRef, RetryPolicy # type: ignore[reportMissingImports]
from .runner import RecordingRunner, SubprocessRunner # type: ignore[reportMissingImports]
from .service import Tracker # type: ignore[reportMissingImports]
__all__ = [
"CompletedCommand",
"Envelope",
"RecordingRunner",
"ResourceRef",
"RetryPolicy",
"SubprocessRunner",
"Tracker",
"TrackerError",
"resolve_provider",
]
+5
View File
@@ -0,0 +1,5 @@
from .cli import main # type: ignore[reportMissingImports]
if __name__ == "__main__":
raise SystemExit(main())
+328
View File
@@ -0,0 +1,328 @@
import json
from .models import normalize_resource # type: ignore[reportMissingImports]
JSON_FIELDS = "number,title,body,state,labels,comments,author,assignees,url,createdAt,updatedAt"
class Adapter:
provider = ""
executable = ""
def __init__(self, repo=None):
self.repo = repo
def _repo_args(self):
return ["--repo", self.repo] if self.repo else []
def command(self, operation, **kwargs):
method = getattr(self, f"command_{operation.replace('.', '_')}")
return method(**kwargs)
def normalize(self, value, kind):
return normalize_resource(value, kind=kind, provider=self.provider)
def json_value(self, stdout):
text = (stdout or "").strip()
if not text:
return {}
try:
return json.loads(text)
except (TypeError, ValueError):
return {"output": text}
class GitHubAdapter(Adapter):
provider = "github"
executable = "gh"
def command_issue_create(self, title, body, labels, assignees):
args = ["gh", "issue", "create", "--title", title, "--body", body]
for label in labels:
args += ["--label", label]
for user in assignees:
args += ["--assignee", user]
return args + self._repo_args()
def command_pr_create(self, title, body, head, base, labels, assignees):
args = ["gh", "pr", "create", "--title", title, "--body", body]
if head:
args += ["--head", head]
if base:
args += ["--base", base]
for label in labels:
args += ["--label", label]
for user in assignees:
args += ["--assignee", user]
return args + self._repo_args()
def _view(self, kind, number, comments=True):
command = "pr" if kind == "pr" else "issue"
args = ["gh", command, "view", str(number)]
if comments:
args.append("--comments")
return args + ["--json", JSON_FIELDS] + self._repo_args()
def command_issue_get(self, number, comments=True):
return self._view("issue", number, comments)
def command_pr_get(self, number, comments=True):
return self._view("pr", number, comments)
def command_issue_list(self, state, labels, limit):
args = ["gh", "issue", "list", "--state", state, "--limit", str(limit)]
for label in labels:
args += ["--label", label]
return args + ["--json", JSON_FIELDS] + self._repo_args()
def command_pr_list(self, state, limit):
return ["gh", "pr", "list", "--state", state, "--limit", str(limit), "--json", JSON_FIELDS] + self._repo_args()
def _edit(self, kind, number, title=None, body=None, add_label=None, remove_label=None, assignee=None, unassign=None):
command = "pr" if kind == "pr" else "issue"
args = ["gh", command, "edit", str(number)]
if title is not None:
args += ["--title", title]
if body is not None:
args += ["--body", body]
if add_label:
args += ["--add-label", add_label]
if remove_label:
args += ["--remove-label", remove_label]
if assignee:
args += ["--add-assignee", assignee]
if unassign:
args += ["--remove-assignee", unassign]
return args + self._repo_args()
def command_issue_edit(self, **kwargs):
return self._edit("issue", **kwargs)
def command_pr_edit(self, **kwargs):
return self._edit("pr", **kwargs)
def command_issue_comment(self, number, body):
return ["gh", "issue", "comment", str(number), "--body", body] + self._repo_args()
def command_pr_comment(self, number, body):
return ["gh", "pr", "comment", str(number), "--body", body] + self._repo_args()
def command_issue_close(self, number):
return ["gh", "issue", "close", str(number)] + self._repo_args()
def command_pr_close(self, number):
return ["gh", "pr", "close", str(number)] + self._repo_args()
def command_pr_diff(self, number):
return ["gh", "pr", "diff", str(number)] + self._repo_args()
def command_label_list(self):
return ["gh", "label", "list", "--limit", "1000", "--json", "name,color,description"] + self._repo_args()
def command_label_create(self, name, color, description):
args = ["gh", "label", "create", name, "--color", color]
if description:
args += ["--description", description]
return args + self._repo_args()
def command_issue_resolve(self, number):
return self.command_issue_get(number)
class GitLabAdapter(Adapter):
provider = "gitlab"
executable = "glab"
def _format(self):
return ["-F", "json"]
def _surface(self, kind):
return "mr" if kind == "pr" else "issue"
def command_issue_create(self, title, body, labels, assignees):
args = ["glab", "issue", "create", "--title", title, "--description", body]
if labels:
args += ["--label", ",".join(labels)]
if assignees:
args += ["--assignee", ",".join(assignees)]
return args + self._repo_args()
def command_pr_create(self, title, body, head, base, labels, assignees):
args = ["glab", "mr", "create", "--title", title, "--description", body]
if head:
args += ["--source-branch", head]
if base:
args += ["--target-branch", base]
if labels:
args += ["--label", ",".join(labels)]
if assignees:
args += ["--assignee", ",".join(assignees)]
return args + self._repo_args()
def command_issue_get(self, number, comments=True):
args = ["glab", "issue", "view", str(number)]
if comments:
args.append("--comments")
return args + self._format() + self._repo_args()
def command_pr_get(self, number, comments=True):
args = ["glab", "mr", "view", str(number)]
if comments:
args.append("--comments")
return args + self._format() + self._repo_args()
def command_issue_list(self, state, labels, limit):
args = ["glab", "issue", "list", "--state", state, "--per-page", str(limit)]
if labels:
args += ["--label", ",".join(labels)]
return args + self._format() + self._repo_args()
def command_pr_list(self, state, limit):
return ["glab", "mr", "list", "--state", state, "--per-page", str(limit)] + self._format() + self._repo_args()
def _edit(self, kind, number, title=None, body=None, add_label=None, remove_label=None, assignee=None, unassign=None):
args = ["glab", self._surface(kind), "update", str(number)]
if title is not None:
args += ["--title", title]
if body is not None:
args += ["--description", body]
if add_label:
args += ["--label", add_label]
if remove_label:
args += ["--unlabel", remove_label]
if assignee:
args += ["--assignee", assignee]
if unassign:
args += ["--unassign", unassign]
return args + self._repo_args()
def command_issue_edit(self, **kwargs):
return self._edit("issue", **kwargs)
def command_pr_edit(self, **kwargs):
return self._edit("pr", **kwargs)
def command_issue_comment(self, number, body):
return ["glab", "issue", "note", str(number), "--message", body] + self._repo_args()
def command_pr_comment(self, number, body):
return ["glab", "mr", "note", str(number), "--message", body] + self._repo_args()
def command_issue_close(self, number):
return ["glab", "issue", "close", str(number)] + self._repo_args()
def command_pr_close(self, number):
return ["glab", "mr", "close", str(number)] + self._repo_args()
def command_pr_diff(self, number):
return ["glab", "mr", "diff", str(number)] + self._repo_args()
def command_label_list(self):
return ["glab", "label", "list"] + self._format() + self._repo_args()
def command_label_create(self, name, color, description):
args = ["glab", "label", "create", name, "--color", color]
if description:
args += ["--description", description]
return args + self._repo_args()
class GiteaAdapter(Adapter):
provider = "gitea"
executable = "tea"
def _format(self):
return ["-o", "json"]
def _surface(self, kind):
return "pr" if kind == "pr" else "issue"
def command_issue_create(self, title, body, labels, assignees):
args = ["tea", "issue", "create", "--title", title, "--description", body]
if labels:
args += ["--labels", ",".join(labels)]
if assignees:
args += ["--assignees", ",".join(assignees)]
return args + self._repo_args()
def command_pr_create(self, title, body, head, base, labels, assignees):
args = ["tea", "pr", "create", "--title", title, "--description", body]
if head:
args += ["--head", head]
if base:
args += ["--base", base]
if labels:
args += ["--labels", ",".join(labels)]
if assignees:
args += ["--assignees", ",".join(assignees)]
return args + self._repo_args()
def _view(self, kind, number, comments=True):
args = ["tea", self._surface(kind), str(number)]
if comments:
args.append("--comments")
return args + self._format() + self._repo_args()
def command_issue_get(self, number, comments=True):
return self._view("issue", number, comments)
def command_pr_get(self, number, comments=True):
return self._view("pr", number, comments)
def command_issue_list(self, state, labels, limit):
args = ["tea", "issue", "list", "--state", state, "--limit", str(limit)]
if labels:
args += ["--labels", ",".join(labels)]
return args + self._format() + self._repo_args()
def command_pr_list(self, state, limit):
return ["tea", "pr", "list", "--state", state, "--limit", str(limit)] + self._format() + self._repo_args()
def _edit(self, kind, number, title=None, body=None, add_label=None, remove_label=None, assignee=None, unassign=None):
args = ["tea", self._surface(kind), "edit", str(number)]
if title is not None:
args += ["--title", title]
if body is not None:
args += ["--description", body]
if add_label:
args += ["--add-label", add_label]
if remove_label:
args += ["--remove-label", remove_label]
if assignee:
args += ["--add-assignee", assignee]
if unassign:
args += ["--remove-assignee", unassign]
return args + self._repo_args()
def command_issue_edit(self, **kwargs):
return self._edit("issue", **kwargs)
def command_pr_edit(self, **kwargs):
return self._edit("pr", **kwargs)
def command_issue_comment(self, number, body):
return ["tea", "comment", str(number), body] + self._repo_args()
def command_pr_comment(self, number, body):
return ["tea", "comment", str(number), body] + self._repo_args()
def command_issue_close(self, number):
return ["tea", "issue", "close", str(number)] + self._repo_args()
def command_pr_close(self, number):
return ["tea", "pr", "close", str(number)] + self._repo_args()
def command_pr_diff(self, number):
return ["tea", "api", f"/repos/{{owner}}/{{repo}}/pulls/{number}.diff"] + self._repo_args()
def command_label_list(self):
return ["tea", "label", "list"] + self._format() + self._repo_args()
def command_label_create(self, name, color, description):
args = ["tea", "label", "create", "--name", name, "--color", color]
if description:
args += ["--description", description]
return args + self._repo_args()
ADAPTERS = {"github": GitHubAdapter, "gitlab": GitLabAdapter, "gitea": GiteaAdapter}
+234
View File
@@ -0,0 +1,234 @@
import argparse
import json
import sys
from .errors import TrackerError # type: ignore[reportMissingImports]
from .models import Envelope, RetryPolicy # type: ignore[reportMissingImports]
from .service import Tracker # type: ignore[reportMissingImports]
class JsonArgumentParser(argparse.ArgumentParser):
def error(self, message):
raise TrackerError("invalid_input", message)
def _parse_attempts(value):
try:
return int(value)
except (TypeError, ValueError) as error:
raise TrackerError("invalid_input", f"invalid retry attempt count: {value}") from error
def _take_global_options(argv):
remaining = []
provider = repo = None
attempts = 3
index = 0
while index < len(argv):
item = argv[index]
if item == "--provider" and index + 1 < len(argv):
provider = argv[index + 1]
index += 2
elif item.startswith("--provider="):
provider = item.split("=", 1)[1]
index += 1
elif item == "--repo" and index + 1 < len(argv):
repo = argv[index + 1]
index += 2
elif item.startswith("--repo="):
repo = item.split("=", 1)[1]
index += 1
elif item == "--retry-attempts" and index + 1 < len(argv):
attempts = _parse_attempts(argv[index + 1])
index += 2
elif item.startswith("--retry-attempts="):
attempts = _parse_attempts(item.split("=", 1)[1])
index += 1
else:
remaining.append(item)
index += 1
return remaining, provider, repo, attempts
def _add_resource_commands(subparsers, kind):
resource = subparsers.add_parser(kind)
commands = resource.add_subparsers(dest="action", required=True)
create = commands.add_parser("create")
create.add_argument("--title", required=True)
create.add_argument("--body", default="")
create.add_argument("--label", action="append", default=[])
create.add_argument("--assignee", action="append", default=[])
if kind == "pr":
create.add_argument("--head")
create.add_argument("--base")
get = commands.add_parser("get")
get.add_argument("number", type=int)
get.add_argument("--no-comments", action="store_true")
if kind == "pr":
get.add_argument("--diff", action="store_true")
listing = commands.add_parser("list")
listing.add_argument("--state", default="open")
listing.add_argument("--label", action="append", default=[])
listing.add_argument("--limit", type=int, default=100)
if kind == "pr":
listing.add_argument("--external-only", action="store_true")
comment = commands.add_parser("comment")
comment.add_argument("number", type=int)
comment.add_argument("--body", required=True)
edit = commands.add_parser("edit")
edit.add_argument("number", type=int)
edit.add_argument("--title")
edit.add_argument("--body")
assign = commands.add_parser("assign")
assign.add_argument("number", type=int)
assign.add_argument("--user", required=True)
close = commands.add_parser("close")
close.add_argument("number", type=int)
close.add_argument("--explanation")
if kind == "pr":
diff = commands.add_parser("diff")
diff.add_argument("number", type=int)
return resource
def build_parser():
parser = JsonArgumentParser(prog="tracker")
commands = parser.add_subparsers(dest="resource", required=True)
_add_resource_commands(commands, "issue")
_add_resource_commands(commands, "pr")
labels = commands.add_parser("label")
label_commands = labels.add_subparsers(dest="action", required=True)
ensure = label_commands.add_parser("ensure")
ensure.add_argument("name")
ensure.add_argument("--color", default="ededed")
ensure.add_argument("--description")
add = label_commands.add_parser("add")
add.add_argument("kind", choices=("issue", "pr"))
add.add_argument("number", type=int)
add.add_argument("name")
remove = label_commands.add_parser("remove")
remove.add_argument("kind", choices=("issue", "pr"))
remove.add_argument("number", type=int)
remove.add_argument("name")
map_parser = commands.add_parser("map")
map_commands = map_parser.add_subparsers(dest="action", required=True)
map_create = map_commands.add_parser("create")
map_create.add_argument("--title", required=True)
map_create.add_argument("--body", default="")
map_create.add_argument("--label", action="append", default=[])
child = commands.add_parser("child")
child_commands = child.add_subparsers(dest="action", required=True)
child_create = child_commands.add_parser("create")
child_create.add_argument("map_number", type=int)
child_create.add_argument("--title", required=True)
child_create.add_argument("--type", dest="wayfinder_type", choices=("research", "prototype", "grilling", "task"), default="task")
child_create.add_argument("--body", default="")
child_create.add_argument("--label", action="append", default=[])
dependency = commands.add_parser("dependency")
dependency_commands = dependency.add_subparsers(dest="action", required=True)
dependency_add = dependency_commands.add_parser("add")
dependency_add.add_argument("child", type=int)
dependency_add.add_argument("blocker", type=int)
frontier = commands.add_parser("frontier")
frontier.add_argument("map_number", type=int)
claim = commands.add_parser("claim")
claim.add_argument("kind", choices=("issue", "pr"))
claim.add_argument("number", type=int)
claim.add_argument("--user")
resolve = commands.add_parser("resolve")
resolve.add_argument("kind", choices=("issue", "pr"))
resolve.add_argument("number", type=int)
resolve.add_argument("--answer", required=True)
resolve.add_argument("--map", dest="map_number", type=int)
reference = commands.add_parser("resolve-reference")
reference.add_argument("number", type=int)
return parser
def _dispatch(tracker, args):
resource = args.resource
if resource in ("issue", "pr"):
if args.action == "create":
method = tracker.create_issue if resource == "issue" else tracker.create_pr
kwargs = {"body": args.body, "labels": args.label, "assignees": args.assignee}
if resource == "pr":
kwargs.update(head=args.head, base=args.base)
return method(args.title, **kwargs)
if args.action == "get":
if resource == "issue":
return tracker.get_issue(args.number, comments=not args.no_comments)
return tracker.get_pr(args.number, comments=not args.no_comments, diff=args.diff)
if args.action == "list":
if resource == "issue":
return tracker.list_issues(state=args.state, labels=args.label, limit=args.limit)
return tracker.list_prs(state=args.state, limit=args.limit, external_only=args.external_only)
if args.action == "comment":
return tracker.comment(resource, args.number, args.body)
if args.action == "edit":
return (tracker.edit_issue if resource == "issue" else tracker.edit_pr)(args.number, title=args.title, body=args.body)
if args.action == "assign":
return tracker.assign(resource, args.number, args.user)
if args.action == "close":
return tracker.close(resource, args.number, explanation=args.explanation)
if args.action == "diff":
return tracker.diff(args.number)
if resource == "label":
if args.action == "ensure":
return tracker.ensure_label(args.name, color=args.color, description=args.description)
if args.action == "add":
return tracker.add_label(args.kind, args.number, args.name)
return tracker.remove_label(args.kind, args.number, args.name)
if resource == "map":
return tracker.create_map(args.title, body=args.body, labels=args.label)
if resource == "child":
return tracker.create_child(args.map_number, args.title, wayfinder_type=args.wayfinder_type, body=args.body, labels=args.label)
if resource == "dependency":
return tracker.add_dependency(args.child, args.blocker)
if resource == "frontier":
return tracker.frontier(args.map_number)
if resource == "claim":
return tracker.claim(args.kind, args.number, user=args.user)
if resource == "resolve":
return tracker.resolve(args.kind, args.number, args.answer, map_number=args.map_number)
return tracker.resolve_reference(args.number)
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
try:
command_argv, provider, repo, attempts = _take_global_options(argv)
args = build_parser().parse_args(command_argv)
tracker = Tracker(provider=provider, repo=repo, retry=RetryPolicy(attempts=attempts))
envelope = _dispatch(tracker, args)
except TrackerError as error:
operation = error.operation
if operation is None:
operation = "cli"
error.operation = operation
envelope = Envelope(False, getattr(error, "provider", None), operation, error=error.to_dict()).to_dict()
except (ValueError, TypeError, OSError) as error:
failure = TrackerError("invalid_input", str(error), operation="cli")
envelope = Envelope(False, None, "cli", error=failure.to_dict()).to_dict()
print(json.dumps(envelope, sort_keys=True))
return 0 if envelope.get("ok") else 1
if __name__ == "__main__":
raise SystemExit(main())
+53
View File
@@ -0,0 +1,53 @@
import os
import re
from .errors import TrackerError # type: ignore[reportMissingImports]
SUPPORTED_PROVIDERS = ("github", "gitlab", "gitea")
def _validate_provider(value):
provider = (value or "").strip().lower()
if provider not in SUPPORTED_PROVIDERS:
raise TrackerError(
"invalid_provider",
f"unsupported provider: {value}",
details={"supported": list(SUPPORTED_PROVIDERS)},
)
return provider
def _providers_from_remote(remote):
host_match = re.search(r"(?:https?://|ssh://|git@)([^/:]+)", remote or "")
host = host_match.group(1).lower() if host_match else ""
found = []
if host == "github.com" or "github" in host:
found.append("github")
if host == "gitlab.com" or "gitlab" in host:
found.append("gitlab")
if "gitea" in host:
found.append("gitea")
return found
def resolve_provider(explicit=None, *, env=None, remote=None):
"""Resolve provider using CLI, environment, then remote precedence."""
if explicit is not None:
return _validate_provider(explicit)
values = os.environ if env is None else env
configured = values.get("TRACKER_PROVIDER")
if configured:
return _validate_provider(configured)
matches = _providers_from_remote(remote or "")
if len(matches) == 1:
return matches[0]
if not remote:
raise TrackerError(
"provider_detection_failed",
"provider was not specified and no Git remote was available",
)
if len(matches) > 1:
message = "Git remote matches multiple supported providers"
else:
message = f"unsupported or unrecognised Git remote: {remote}"
raise TrackerError("provider_detection_failed", message, details={"remote": remote})
+21
View File
@@ -0,0 +1,21 @@
class TrackerError(Exception):
"""A stable, user-facing tracker failure."""
def __init__(self, code, message, *, retryable=False, provider=None, operation=None, details=None):
super().__init__(message)
self.code = code
self.message = message
self.retryable = retryable
self.provider = provider
self.operation = operation
self.details = details or {}
def to_dict(self):
return {
"code": self.code,
"message": self.message,
"retryable": self.retryable,
"provider": self.provider,
"operation": self.operation,
"details": self.details,
}
+95
View File
@@ -0,0 +1,95 @@
from dataclasses import dataclass, field
from typing import Any
@dataclass
class CompletedCommand:
stdout: str = ""
stderr: str = ""
returncode: int = 0
@dataclass
class RetryPolicy:
attempts: int = 3
delay: float = 0.0
def __post_init__(self):
if self.attempts < 1:
raise ValueError("retry attempts must be at least one")
@dataclass(frozen=True)
class ResourceRef:
kind: str
number: int
def __post_init__(self):
if self.kind not in {"issue", "pr"}:
raise ValueError("resource kind must be issue or pr")
if self.number < 1:
raise ValueError("resource number must be positive")
@dataclass
class Envelope:
ok: bool
provider: str | None
operation: str
result: Any = None
details: dict[str, Any] = field(default_factory=dict)
error: dict[str, Any] | None = None
def to_dict(self):
value = {
"ok": self.ok,
"provider": self.provider,
"operation": self.operation,
}
if self.ok:
value["result"] = self.result
if self.details:
value["details"] = self.details
else:
value["error"] = self.error
return value
def normalize_label(value):
if isinstance(value, str):
return {"name": value}
if not isinstance(value, dict):
return {"name": str(value)}
return {
"name": value.get("name", value.get("title", "")),
**{key: value[key] for key in ("color", "description", "id") if key in value},
}
def normalize_resource(value, *, kind, provider):
"""Normalize the common fields while retaining provider-specific raw data."""
if not isinstance(value, dict):
return {"kind": kind, "number": value, "details": {"raw": value}}
number = value.get("number", value.get("iid", value.get("id")))
labels = value.get("labels", value.get("label", [])) or []
assignees = value.get("assignees", value.get("assignee", [])) or []
state = str(value.get("state", "")).lower()
state = {"opened": "open", "open": "open", "closed": "closed"}.get(state, state)
normalized = {
"kind": kind,
"number": number,
"title": value.get("title", ""),
"body": value.get("body", value.get("description", "")) or "",
"state": state,
"labels": [normalize_label(label) for label in labels],
"assignees": assignees if isinstance(assignees, list) else [assignees],
"author": value.get("author", value.get("author_name", value.get("user"))),
"author_association": value.get("author_association", value.get("authorAssociation")),
"url": value.get("url", value.get("web_url", value.get("html_url"))),
"comments": value.get("comments", value.get("notes", [])) or [],
}
for key in ("draft", "merged", "createdAt", "updatedAt", "source_branch", "target_branch", "authorAssociation", "author_association", "membership"):
if key in value:
normalized[key] = value[key]
normalized["details"] = {"provider": provider, "raw": value}
return normalized
View File
+35
View File
@@ -0,0 +1,35 @@
import subprocess
from collections.abc import Mapping, Sequence
from .models import CompletedCommand # type: ignore[reportMissingImports]
class SubprocessRunner:
"""Small injectable subprocess seam used by every provider adapter."""
def run(self, argv: Sequence[str], *, cwd=None, env: Mapping[str, str] | None = None, timeout=None):
process = subprocess.run(
list(argv),
cwd=str(cwd) if cwd else None,
env=dict(env) if env is not None else None,
timeout=timeout,
capture_output=True,
text=True,
check=False,
)
return CompletedCommand(process.stdout, process.stderr, process.returncode)
class RecordingRunner:
"""Useful public fake runner for consumers and contract tests."""
def __init__(self, responses=None):
self.calls = []
self.responses = list(responses or [])
def run(self, argv, **kwargs):
self.calls.append((list(argv), kwargs))
if self.responses:
response = self.responses.pop(0)
return response if isinstance(response, CompletedCommand) else CompletedCommand(*response)
return CompletedCommand("{}")
+472
View File
@@ -0,0 +1,472 @@
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)