From dead6ca2bb8cfff5360d00ca85c549ff4628e117 Mon Sep 17 00:00:00 2001 From: Steve Beaulac Date: Mon, 17 Aug 2026 22:36:05 -0400 Subject: [PATCH] feat: add provider-neutral tracker automation --- .agent/project-context.md | 22 +- .gitignore | 2 + AGENTS.md | 2 +- README.md | 4 + common/engineering/setup-skills/SKILL.md | 2 + .../setup-skills/issue-tracker-gitea.md | 6 +- .../setup-skills/issue-tracker-github.md | 2 +- .../setup-skills/issue-tracker-gitlab.md | 2 +- docs/agents/issue-tracker.md | 4 +- docs/agents/tracker.md | 15 + pyproject.toml | 24 + tests/__init__.py | 0 tests/test_operations.py | 125 +++++ tests/test_provider_resolution.py | 37 ++ tracker/README.md | 30 ++ tracker/__init__.py | 21 + tracker/__main__.py | 5 + tracker/adapters.py | 328 ++++++++++++ tracker/cli.py | 234 +++++++++ tracker/detection.py | 53 ++ tracker/errors.py | 21 + tracker/models.py | 95 ++++ tracker/py.typed | 0 tracker/runner.py | 35 ++ tracker/service.py | 472 ++++++++++++++++++ tracker_automation/__init__.py | 25 + tracker_automation/__main__.py | 5 + tracker_automation/cli.py | 3 + 28 files changed, 1557 insertions(+), 17 deletions(-) create mode 100644 docs/agents/tracker.md create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/test_operations.py create mode 100644 tests/test_provider_resolution.py create mode 100644 tracker/README.md create mode 100644 tracker/__init__.py create mode 100644 tracker/__main__.py create mode 100644 tracker/adapters.py create mode 100644 tracker/cli.py create mode 100644 tracker/detection.py create mode 100644 tracker/errors.py create mode 100644 tracker/models.py create mode 100644 tracker/py.typed create mode 100644 tracker/runner.py create mode 100644 tracker/service.py create mode 100644 tracker_automation/__init__.py create mode 100644 tracker_automation/__main__.py create mode 100644 tracker_automation/cli.py diff --git a/.agent/project-context.md b/.agent/project-context.md index 0dd6405..7d78585 100644 --- a/.agent/project-context.md +++ b/.agent/project-context.md @@ -1,6 +1,6 @@ # Project Context Pack -Generated: 2026-07-16 +Generated: 2026-08-17 Root: /home/sjb/Projects/personal/ws-sjb-skills/wt-master Working directory: . Status: fresh @@ -11,10 +11,10 @@ A collection of agent skills (slash commands and behaviors) loaded into Steve Be ## Project type -- **Agent skill repository** — markdown-defined agent instructions -- Languages: Markdown (100%), one Bash script (detect-agent), one shell script (tmux-open) -- Package managers: none -- Build/test tools: none +- **Agent skill repository plus standalone Python tracker package** +- Languages: Python and Markdown, one Bash script (detect-agent), one shell script (tmux-open) +- Package manager: `pyproject.toml` +- Build/test tools: `python -m unittest discover` - Agent guidance: `AGENTS.md` at root, `docs/invocation.md` for invocation conventions, `docs/agents/` for issue tracker / triage labels / ADR wiki / domain docs ## Structure @@ -34,6 +34,10 @@ A collection of agent skills (slash commands and behaviors) loaded into Steve Be │ │ ├── issue-tracker.md # Gitea issue tracker docs │ │ └── triage-labels.md # Five-label triage vocabulary │ └── adr/ # ADR wiki clone (gitignored) +├── tracker/ # Provider-neutral Python CLI/library +├── tracker_automation/ # Compatibility import name +├── tests/ # Public operation-boundary tests +├── pyproject.toml # Package metadata and console scripts └── common/ ├── README.md # Lists all common skills by invocation type ├── engineering/ # Model-invoked: lsp-code-analysis, pkm-curation; User-invoked: commit-staged, implement-issue, project-context-pack, setup-skills @@ -57,10 +61,10 @@ A collection of agent skills (slash commands and behaviors) loaded into Steve Be ## Commands -- Build: none -- Test: none -- Lint/typecheck: none -- Run/dev: skills are invoked by AI agents — no server or dev command +- Build/package: `python -m pip install .` +- Test: `python -m unittest discover -v` +- Typecheck: `lsp_diagnostics` on `tracker/` and `tests/` +- Run: `python -m tracker` or installed `tracker` ## Entry points diff --git a/.gitignore b/.gitignore index c36d5b8..ec084f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ docs/adr/ +__pycache__/ +*.py[cod] diff --git a/AGENTS.md b/AGENTS.md index 75467c1..d4c0ec0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ Every `SKILL.md` is either user-invoked (`disable-model-invocation: true`, reach ### Issue tracker -Issues are tracked in Gitea on gitea.sagacity.ca. See `docs/agents/issue-tracker.md`. +Issues are tracked in Gitea on gitea.sagacity.ca; use the provider-neutral `tracker` package for normal automation. See `docs/agents/issue-tracker.md` and `docs/agents/tracker.md`. ### Triage labels diff --git a/README.md b/README.md index f3d6cec..3f72b5d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Agent skills (slash commands and behaviors) loaded into my agent. +## Tracker automation + +This repo also ships the standalone provider-neutral `tracker` CLI/library. See [`docs/agents/tracker.md`](docs/agents/tracker.md) and [`tracker/README.md`](tracker/README.md) for migration guidance. + ## User-invoked - [agent-handoff](common/in-progress/agent-handoff/SKILL.md) — Hand the current conversation off to a fresh background agent that picks up the work immediately. diff --git a/common/engineering/setup-skills/SKILL.md b/common/engineering/setup-skills/SKILL.md index 20f7339..64ee5dc 100644 --- a/common/engineering/setup-skills/SKILL.md +++ b/common/engineering/setup-skills/SKILL.md @@ -6,6 +6,8 @@ disable-model-invocation: true # Setup Engineering Skills +When the provider-neutral `tracker` package is installed, generated issue-tracker guidance should call it for normal operations and retain provider-specific CLI commands only as adapter/fallback reference. See `docs/agents/tracker.md`. + Scaffold the per-repo configuration that the engineering skills assume: - Issue tracker: where issues live (never assume a default forge) diff --git a/common/engineering/setup-skills/issue-tracker-gitea.md b/common/engineering/setup-skills/issue-tracker-gitea.md index d7ac0c8..e7e6761 100644 --- a/common/engineering/setup-skills/issue-tracker-gitea.md +++ b/common/engineering/setup-skills/issue-tracker-gitea.md @@ -1,12 +1,12 @@ # Issue tracker: Gitea -Issues and PRDs for this repo live as Gitea issues. Use the `tea` CLI for all operations. +Prefer the provider-neutral `tracker` command for normal operations; it emits JSON and delegates to `tea`. See `docs/agents/tracker.md`. The `tea` commands below remain adapter and capability reference material. ## Conventions -- **Create an issue**: `tea issue create --title "..." --description "..."`. Use a heredoc for multi-line descriptions. +- **Create an issue**: `tea issue create --title "..." --description "..."`. Use a heredoc for multi-line descriptions. - **Read an issue**: `tea issue --comments`. Use `-o json` for machine-readable output. -- **List issues**: `tea issue list --state open -o json` with appropriate `--labels` and `--state` filters. +- **List issues**: `tea issue list --state open -o json` with appropriate `--labels` and `--state` filters. - **Comment on an issue**: `tea comment "..."`. - **Apply / remove labels**: `tea issue edit --add-label "..."` / `--remove-label "..."`. Multiple labels can be comma-separated or by repeating the flag. Labels need to be created first with `tea label create --name "..." --color "..."`. - **Close**: `tea issue close `. `tea issue close` does not accept a closing comment, so post the explanation first with `tea comment "..."`, then close. diff --git a/common/engineering/setup-skills/issue-tracker-github.md b/common/engineering/setup-skills/issue-tracker-github.md index 82cfbf5..79a5303 100644 --- a/common/engineering/setup-skills/issue-tracker-github.md +++ b/common/engineering/setup-skills/issue-tracker-github.md @@ -1,6 +1,6 @@ # Issue tracker: GitHub -Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations. +Prefer the provider-neutral `tracker` command for normal operations; it emits JSON and delegates to `gh`. See `docs/agents/tracker.md`. The `gh` commands below remain adapter and capability reference material. ## Conventions diff --git a/common/engineering/setup-skills/issue-tracker-gitlab.md b/common/engineering/setup-skills/issue-tracker-gitlab.md index 8a54714..51b4313 100644 --- a/common/engineering/setup-skills/issue-tracker-gitlab.md +++ b/common/engineering/setup-skills/issue-tracker-gitlab.md @@ -1,6 +1,6 @@ # Issue tracker: GitLab -Issues and PRDs for this repo live as GitLab issues. Use the [`glab`](https://gitlab.com/gitlab-org/cli) CLI for all operations. +Prefer the provider-neutral `tracker` command for normal operations; it emits JSON and delegates to `glab`. See `docs/agents/tracker.md`. The `glab` commands below remain adapter and capability reference material. ## Conventions diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index 20035bb..bed7ea0 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -1,6 +1,6 @@ -# Issue tracker: Gitea +# Issue tracker: provider-neutral tracker over Gitea -Issues and PRDs for this repo live as Gitea issues. Use the `tea` CLI for all operations. +Use `tracker` (see `docs/agents/tracker.md`) for normal issue and pull-request automation. It delegates to `tea` and emits one JSON envelope. The Gitea command details below remain capability and fallback reference material; do not issue exploratory raw commands when a tracker operation exists. ## Conventions diff --git a/docs/agents/tracker.md b/docs/agents/tracker.md new file mode 100644 index 0000000..ec3ae61 --- /dev/null +++ b/docs/agents/tracker.md @@ -0,0 +1,15 @@ +# Provider-neutral tracker automation + +Use `tracker` for normal issue and pull-request automation. It owns the high-level operation, prerequisite checks, normalization, bounded retry policy, and JSON protocol; it delegates credentials and provider commands to `gh`, `glab`, or `tea`. + +```sh +tracker --provider gitea issue get 16 +TRACKER_PROVIDER=gitlab tracker issue list --state open --label ready-for-agent +tracker pr get 42 --diff +``` + +Provider selection is explicit CLI flag, then `TRACKER_PROVIDER`, then the `origin` remote. Always choose `issue` or `pr` explicitly for reads and writes. Use `resolve-reference` only for an intentionally ambiguous bare number. + +Parse `ok` and `error.code`; do not parse provider output or issue exploratory retries. The library (`from tracker import Tracker`) returns the same envelope as the CLI. `RecordingRunner` provides the fake subprocess seam for tests. + +Provider-specific capability and fallback references remain in `docs/agents/issue-tracker.md` and the setup templates. They are not the normal execution path. Wayfinding relationships may use native provider APIs where available and task-list/body or note fallbacks otherwise; the result's `details` identifies the relationship mode. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..893f5d4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "tracker-automation" +version = "0.1.0" +description = "Provider-neutral tracker automation CLI and library" +requires-python = ">=3.10" + +[project.scripts] +tracker = "tracker.cli:main" +tracker-automation = "tracker.cli:main" + +[tool.setuptools.packages.find] +include = ["tracker*", "tracker_automation*"] + +[tool.pyright] +include = ["tracker", "tests"] +extraPaths = ["."] + +[tool.ruff] +target-version = "py310" +line-length = 100 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_operations.py b/tests/test_operations.py new file mode 100644 index 0000000..119abd3 --- /dev/null +++ b/tests/test_operations.py @@ -0,0 +1,125 @@ +import unittest + +from tracker import CompletedCommand, RecordingRunner, RetryPolicy, Tracker # type: ignore[reportMissingImports] + + +class TrackerOperationTests(unittest.TestCase): + def test_add_label_ensures_missing_label_before_assignment(self): + runner = RecordingRunner( + [ + CompletedCommand("[]"), + CompletedCommand('{"name":"ready","color":"ededed"}'), + CompletedCommand('{"number":7,"labels":[{"name":"ready"}]}'), + ] + ) + result = Tracker(provider="github", runner=runner).add_label("issue", 7, "ready") + + self.assertTrue(result["ok"]) + self.assertEqual([call[0][:3] for call in runner.calls], [["gh", "label", "list"], ["gh", "label", "create"], ["gh", "issue", "edit"]]) + self.assertEqual(result["details"]["ensured"]["created"], True) + + def test_existing_label_is_not_recreated(self): + runner = RecordingRunner( + [ + CompletedCommand('[{"name":"ready","color":"ff0000"}]'), + CompletedCommand('{"number":7,"labels":[{"name":"ready"}]}'), + ] + ) + result = Tracker(provider="gitea", runner=runner).add_label("issue", 7, "ready") + + self.assertTrue(result["ok"]) + self.assertEqual(len(runner.calls), 2) + self.assertEqual(result["details"]["ensured"]["created"], False) + + def test_transient_failure_is_retried_and_normalized(self): + runner = RecordingRunner( + [ + CompletedCommand("", "connection reset", 1), + CompletedCommand('{"number":4,"iid":4,"title":"Fix","description":"body","state":"opened","labels":[]}'), + ] + ) + result = Tracker(provider="gitlab", runner=runner, retry=RetryPolicy(attempts=2)).get_issue(4) + + self.assertTrue(result["ok"]) + self.assertEqual(len(runner.calls), 2) + self.assertEqual(result["result"]["number"], 4) + self.assertEqual(result["result"]["body"], "body") + + def test_external_pr_filter_normalizes_github_associations(self): + runner = RecordingRunner([ + CompletedCommand('[{"number":1,"title":"inside","authorAssociation":"MEMBER"},{"number":2,"title":"outside","authorAssociation":"NONE"}]') + ]) + result = Tracker(provider="github", runner=runner).list_external_prs() + + self.assertTrue(result["ok"]) + self.assertEqual([item["number"] for item in result["result"]], [2]) + + def test_external_filter_reports_missing_membership_metadata(self): + runner = RecordingRunner([CompletedCommand('[{"number":1,"title":"unknown"}]')]) + result = Tracker(provider="gitea", runner=runner).list_external_prs() + + self.assertFalse(result["ok"]) + self.assertEqual(result["error"]["code"], "unsupported_capability") + + def test_pr_get_can_include_diff(self): + runner = RecordingRunner([ + CompletedCommand('{"number":3,"title":"Change","state":"open"}'), + CompletedCommand("diff --git a/a b/a"), + ]) + result = Tracker(provider="gitlab", runner=runner).get_pr(3, diff=True) + + self.assertTrue(result["ok"]) + self.assertEqual(result["result"]["diff"], "diff --git a/a b/a") + + def test_child_creation_links_native_and_updates_map_order(self): + runner = RecordingRunner([ + CompletedCommand("[]"), + CompletedCommand("{}"), + CompletedCommand('{"number":7,"title":"Research"}'), + CompletedCommand("{}"), + CompletedCommand('{"number":9,"body":"Notes"}'), + CompletedCommand("{}"), + ]) + result = Tracker(provider="github", runner=runner).create_child(9, "Research", wayfinder_type="research") + + self.assertTrue(result["ok"]) + self.assertEqual(result["result"]["number"], 7) + self.assertEqual(result["details"]["relationship"], "native") + self.assertTrue(result["details"]["map_updated"]) + self.assertTrue(any("sub_issues" in part for part in runner.calls[3][0])) + + def test_resolve_reports_completed_steps_after_partial_failure(self): + runner = RecordingRunner([ + CompletedCommand("{}"), + CompletedCommand("", "connection reset", 1), + ]) + result = Tracker(provider="gitea", runner=runner, retry=RetryPolicy(attempts=1)).resolve("issue", 7, "answer") + + self.assertFalse(result["ok"]) + self.assertEqual(result["error"]["code"], "partial_failure") + self.assertEqual(result["error"]["details"]["completed"], ["comment"]) + + def test_frontier_uses_map_task_order_and_filters_closed_or_claimed(self): + runner = RecordingRunner([ + CompletedCommand('{"number":9,"body":"- [ ] #2 second\\n- [ ] #1 first","state":"open"}'), + CompletedCommand('{"number":2,"title":"second","state":"open","assignees":[]}'), + CompletedCommand('{"number":1,"title":"first","state":"closed","assignees":[]}'), + ]) + result = Tracker(provider="github", runner=runner).frontier(9) + + self.assertTrue(result["ok"]) + self.assertEqual([item["number"] for item in result["result"]], [2]) + self.assertTrue(result["details"]["deterministic"]) + + def test_authentication_failure_is_not_retryable(self): + runner = RecordingRunner([CompletedCommand("", "not logged in", 1)]) + result = Tracker(provider="github", runner=runner).get_issue(4) + + self.assertFalse(result["ok"]) + self.assertEqual(result["error"]["code"], "auth_required") + self.assertFalse(result["error"]["retryable"]) + self.assertEqual(len(runner.calls), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_provider_resolution.py b/tests/test_provider_resolution.py new file mode 100644 index 0000000..5f6463f --- /dev/null +++ b/tests/test_provider_resolution.py @@ -0,0 +1,37 @@ +import unittest + +from tracker import TrackerError, resolve_provider # type: ignore[reportMissingImports] + + +class ProviderResolutionTests(unittest.TestCase): + def test_cli_provider_wins_over_environment_and_remote(self): + self.assertEqual( + resolve_provider( + explicit="github", + env={"TRACKER_PROVIDER": "gitlab"}, + remote="https://gitea.example.com/team/repo.git", + ), + "github", + ) + + def test_environment_provider_wins_over_remote(self): + self.assertEqual( + resolve_provider( + env={"TRACKER_PROVIDER": "gitlab"}, + remote="git@github.com:team/repo.git", + ), + "gitlab", + ) + + def test_remote_provider_is_detected(self): + self.assertEqual(resolve_provider(remote="git@gitea.example.com:team/repo.git"), "gitea") + + def test_unsupported_remote_is_structured_error(self): + with self.assertRaises(TrackerError) as context: + resolve_provider(remote="https://example.com/team/repo.git") + self.assertEqual(context.exception.code, "provider_detection_failed") + self.assertFalse(context.exception.retryable) + + +if __name__ == "__main__": + unittest.main() diff --git a/tracker/README.md b/tracker/README.md new file mode 100644 index 0000000..d720701 --- /dev/null +++ b/tracker/README.md @@ -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. diff --git a/tracker/__init__.py b/tracker/__init__.py new file mode 100644 index 0000000..e29f6f8 --- /dev/null +++ b/tracker/__init__.py @@ -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", +] diff --git a/tracker/__main__.py b/tracker/__main__.py new file mode 100644 index 0000000..5bb2c51 --- /dev/null +++ b/tracker/__main__.py @@ -0,0 +1,5 @@ +from .cli import main # type: ignore[reportMissingImports] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tracker/adapters.py b/tracker/adapters.py new file mode 100644 index 0000000..a8e2e66 --- /dev/null +++ b/tracker/adapters.py @@ -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} diff --git a/tracker/cli.py b/tracker/cli.py new file mode 100644 index 0000000..ad0d92e --- /dev/null +++ b/tracker/cli.py @@ -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()) diff --git a/tracker/detection.py b/tracker/detection.py new file mode 100644 index 0000000..e1d44ab --- /dev/null +++ b/tracker/detection.py @@ -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}) diff --git a/tracker/errors.py b/tracker/errors.py new file mode 100644 index 0000000..1e54192 --- /dev/null +++ b/tracker/errors.py @@ -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, + } diff --git a/tracker/models.py b/tracker/models.py new file mode 100644 index 0000000..68220dd --- /dev/null +++ b/tracker/models.py @@ -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 diff --git a/tracker/py.typed b/tracker/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tracker/runner.py b/tracker/runner.py new file mode 100644 index 0000000..f22ec22 --- /dev/null +++ b/tracker/runner.py @@ -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("{}") diff --git a/tracker/service.py b/tracker/service.py new file mode 100644 index 0000000..6e8d3b5 --- /dev/null +++ b/tracker/service.py @@ -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) diff --git a/tracker_automation/__init__.py b/tracker_automation/__init__.py new file mode 100644 index 0000000..17931e2 --- /dev/null +++ b/tracker_automation/__init__.py @@ -0,0 +1,25 @@ +"""Compatibility import name for the tracker automation package.""" + +from tracker import ( # type: ignore[reportMissingImports] + CompletedCommand, + Envelope, + RecordingRunner, + ResourceRef, + RetryPolicy, + SubprocessRunner, + Tracker, + TrackerError, + resolve_provider, +) + +__all__ = [ + "CompletedCommand", + "Envelope", + "RecordingRunner", + "ResourceRef", + "RetryPolicy", + "SubprocessRunner", + "Tracker", + "TrackerError", + "resolve_provider", +] diff --git a/tracker_automation/__main__.py b/tracker_automation/__main__.py new file mode 100644 index 0000000..5bb2c51 --- /dev/null +++ b/tracker_automation/__main__.py @@ -0,0 +1,5 @@ +from .cli import main # type: ignore[reportMissingImports] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tracker_automation/cli.py b/tracker_automation/cli.py new file mode 100644 index 0000000..7363fdc --- /dev/null +++ b/tracker_automation/cli.py @@ -0,0 +1,3 @@ +from tracker.cli import build_parser, main # type: ignore[reportMissingImports] + +__all__ = ["build_parser", "main"] -- 2.55.0