feat: provider-neutral tracker automation #25
@@ -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
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
docs/adr/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
from .cli import main # type: ignore[reportMissingImports]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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
@@ -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())
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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("{}")
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
from .cli import main # type: ignore[reportMissingImports]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
from tracker.cli import build_parser, main # type: ignore[reportMissingImports]
|
||||
|
||||
__all__ = ["build_parser", "main"]
|
||||
Reference in New Issue
Block a user