canopy-cli 3.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- canopy/__init__.py +2 -0
- canopy/actions/__init__.py +32 -0
- canopy/actions/aliases.py +421 -0
- canopy/actions/augments.py +55 -0
- canopy/actions/bootstrap.py +249 -0
- canopy/actions/bot_resolutions.py +123 -0
- canopy/actions/bot_status.py +133 -0
- canopy/actions/commit.py +511 -0
- canopy/actions/conflicts.py +314 -0
- canopy/actions/doctor.py +1459 -0
- canopy/actions/draft_replies.py +185 -0
- canopy/actions/drift.py +241 -0
- canopy/actions/errors.py +115 -0
- canopy/actions/evacuate.py +192 -0
- canopy/actions/feature_state.py +607 -0
- canopy/actions/historian.py +612 -0
- canopy/actions/ide_workspace.py +49 -0
- canopy/actions/last_visit.py +83 -0
- canopy/actions/migrate_slots.py +313 -0
- canopy/actions/preflight_state.py +97 -0
- canopy/actions/push.py +199 -0
- canopy/actions/reads.py +304 -0
- canopy/actions/resume.py +582 -0
- canopy/actions/review_filter.py +135 -0
- canopy/actions/ship.py +399 -0
- canopy/actions/slot_details.py +208 -0
- canopy/actions/slot_load.py +383 -0
- canopy/actions/slots.py +221 -0
- canopy/actions/stash.py +230 -0
- canopy/actions/switch.py +775 -0
- canopy/actions/switch_preflight.py +192 -0
- canopy/actions/thread_actions.py +88 -0
- canopy/actions/thread_resolutions.py +101 -0
- canopy/actions/triage.py +286 -0
- canopy/agent/__init__.py +5 -0
- canopy/agent/runner.py +129 -0
- canopy/agent_setup/__init__.py +264 -0
- canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
- canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
- canopy/cli/__init__.py +0 -0
- canopy/cli/main.py +4152 -0
- canopy/cli/render.py +98 -0
- canopy/cli/ui.py +150 -0
- canopy/features/__init__.py +2 -0
- canopy/features/coordinator.py +1256 -0
- canopy/git/__init__.py +0 -0
- canopy/git/hooks.py +173 -0
- canopy/git/multi.py +435 -0
- canopy/git/repo.py +859 -0
- canopy/git/templates/post-checkout.py +67 -0
- canopy/graph/__init__.py +0 -0
- canopy/integrations/__init__.py +0 -0
- canopy/integrations/github.py +983 -0
- canopy/integrations/linear.py +307 -0
- canopy/integrations/precommit.py +239 -0
- canopy/mcp/__init__.py +0 -0
- canopy/mcp/client.py +329 -0
- canopy/mcp/server.py +1797 -0
- canopy/providers/__init__.py +105 -0
- canopy/providers/github_issues.py +289 -0
- canopy/providers/linear.py +341 -0
- canopy/providers/types.py +149 -0
- canopy/workspace/__init__.py +4 -0
- canopy/workspace/config.py +378 -0
- canopy/workspace/context.py +224 -0
- canopy/workspace/discovery.py +197 -0
- canopy/workspace/workspace.py +173 -0
- canopy_cli-3.1.0.dist-info/METADATA +282 -0
- canopy_cli-3.1.0.dist-info/RECORD +71 -0
- canopy_cli-3.1.0.dist-info/WHEEL +4 -0
- canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Issue-provider injection (M5).
|
|
2
|
+
|
|
3
|
+
Workspaces opt into a provider via the ``[issue_provider]`` block in
|
|
4
|
+
``canopy.toml``. The action layer obtains the configured provider via
|
|
5
|
+
:func:`get_issue_provider` — it never imports a provider module directly.
|
|
6
|
+
|
|
7
|
+
See ``docs/architecture/providers.md`` for the design.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from .types import Issue, IssueProvider, IssueProviderError, IssueNotFoundError, ProviderNotConfigured
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ..workspace.workspace import Workspace
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Issue",
|
|
21
|
+
"IssueProvider",
|
|
22
|
+
"IssueProviderError",
|
|
23
|
+
"IssueNotFoundError",
|
|
24
|
+
"ProviderNotConfigured",
|
|
25
|
+
"get_issue_provider",
|
|
26
|
+
"register_provider",
|
|
27
|
+
"available_providers",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Lazy-imported registry. Backends are imported on first use to avoid
|
|
32
|
+
# pulling in MCP / gh CLI machinery for every action.
|
|
33
|
+
_REGISTRY: dict[str, str] = {
|
|
34
|
+
"linear": "canopy.providers.linear.LinearProvider",
|
|
35
|
+
"github_issues": "canopy.providers.github_issues.GitHubIssuesProvider",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Per-workspace cache. Keyed on the workspace root path so multi-workspace
|
|
39
|
+
# MCP sessions don't share instances.
|
|
40
|
+
_INSTANCES: dict[Path, IssueProvider] = {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def register_provider(name: str, dotted_path: str) -> None:
|
|
44
|
+
"""Register a third-party provider class. Out of band from canopy.toml.
|
|
45
|
+
|
|
46
|
+
Reserved for future entry-point discovery; v1 only ships bundled
|
|
47
|
+
providers but exposing this avoids a refactor when entry points land.
|
|
48
|
+
"""
|
|
49
|
+
_REGISTRY[name] = dotted_path
|
|
50
|
+
# Drop any cached instance for that name.
|
|
51
|
+
for path, instance in list(_INSTANCES.items()):
|
|
52
|
+
if type(instance).__name__ == dotted_path.rsplit(".", 1)[-1]:
|
|
53
|
+
_INSTANCES.pop(path, None)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def available_providers() -> list[str]:
|
|
57
|
+
"""Sorted list of registered provider names."""
|
|
58
|
+
return sorted(_REGISTRY.keys())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_issue_provider(workspace: "Workspace") -> IssueProvider:
|
|
62
|
+
"""Return the configured issue provider for the workspace.
|
|
63
|
+
|
|
64
|
+
Cached per-workspace; constructed lazily on first access.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ProviderNotConfigured: when ``[issue_provider]`` references an
|
|
68
|
+
unknown name. Carries the list of available providers so the
|
|
69
|
+
agent can suggest the right one.
|
|
70
|
+
"""
|
|
71
|
+
root = workspace.config.root
|
|
72
|
+
if root in _INSTANCES:
|
|
73
|
+
return _INSTANCES[root]
|
|
74
|
+
|
|
75
|
+
config = workspace.config.issue_provider
|
|
76
|
+
name = config.name
|
|
77
|
+
dotted = _REGISTRY.get(name)
|
|
78
|
+
if dotted is None:
|
|
79
|
+
raise ProviderNotConfigured(
|
|
80
|
+
f"Unknown issue provider '{name}'. "
|
|
81
|
+
f"Available: {', '.join(available_providers())}",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
cls = _import(dotted)
|
|
85
|
+
# Backends accept their config dict as a positional argument plus
|
|
86
|
+
# ``workspace_root`` as a kwarg. Backends that don't need the root
|
|
87
|
+
# (e.g. a hypothetical purely-credential-driven provider) can ignore
|
|
88
|
+
# it via **kwargs.
|
|
89
|
+
instance = cls(config.options, workspace_root=root)
|
|
90
|
+
_INSTANCES[root] = instance
|
|
91
|
+
return instance
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _import(dotted_path: str):
|
|
95
|
+
"""Resolve a 'module.Class' string to the class object."""
|
|
96
|
+
module_path, _, attr = dotted_path.rpartition(".")
|
|
97
|
+
import importlib
|
|
98
|
+
|
|
99
|
+
module = importlib.import_module(module_path)
|
|
100
|
+
return getattr(module, attr)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _clear_cache() -> None:
|
|
104
|
+
"""Clear the per-workspace instance cache. Test-only."""
|
|
105
|
+
_INSTANCES.clear()
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""GitHub Issues backend for the issue-provider contract.
|
|
2
|
+
|
|
3
|
+
New in M5. Uses the existing ``gh`` CLI helper from
|
|
4
|
+
``integrations/github.py`` (no MCP server required); falls back to
|
|
5
|
+
``BlockerError(code='github_not_configured')`` semantics if ``gh`` isn't
|
|
6
|
+
available.
|
|
7
|
+
|
|
8
|
+
Workspace config under ``[issue_provider.github_issues]`` accepts:
|
|
9
|
+
|
|
10
|
+
- ``repo``: required. ``"owner/repo"`` of the GitHub repository hosting
|
|
11
|
+
the issues for this workspace.
|
|
12
|
+
- ``labels_filter``: optional list of label names. When set, restricts
|
|
13
|
+
``list_my_issues`` to issues bearing at least one of these labels.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .types import (
|
|
23
|
+
Issue,
|
|
24
|
+
IssueNotFoundError,
|
|
25
|
+
IssueProviderError,
|
|
26
|
+
ProviderNotConfigured,
|
|
27
|
+
)
|
|
28
|
+
from ..integrations.github import GitHubNotConfiguredError, _gh
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# GitHub state names → canonical canopy states. GH only has open / closed,
|
|
32
|
+
# but issues with the ``state_reason`` of "completed" vs "not_planned"
|
|
33
|
+
# get split into done / cancelled.
|
|
34
|
+
_GH_STATE_MAP = {
|
|
35
|
+
"open": "in_progress",
|
|
36
|
+
"closed": "done",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Labels we recognize for priority hinting. Order matters — first match
|
|
40
|
+
# wins. Both "p0/p1/..." short forms and "priority/urgent" GH-Issues
|
|
41
|
+
# convention are supported.
|
|
42
|
+
_PRIORITY_LABEL_MAP = {
|
|
43
|
+
"priority/urgent": 1,
|
|
44
|
+
"priority/critical": 1,
|
|
45
|
+
"priority/high": 2,
|
|
46
|
+
"priority/medium": 3,
|
|
47
|
+
"priority/low": 4,
|
|
48
|
+
"p0": 1,
|
|
49
|
+
"p1": 2,
|
|
50
|
+
"p2": 3,
|
|
51
|
+
"p3": 4,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class GitHubIssuesProvider:
|
|
56
|
+
"""GitHub Issues issue provider.
|
|
57
|
+
|
|
58
|
+
Backed by the ``gh`` CLI (the same helper canopy uses for PR data).
|
|
59
|
+
Requires a configured ``repo`` in canopy.toml; aliases get parsed as
|
|
60
|
+
issue numbers (``#142``, ``142``, or ``owner/repo#142``).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, options: dict[str, Any] | None = None, *, workspace_root: Path | None = None):
|
|
64
|
+
self._options = options or {}
|
|
65
|
+
self._workspace_root = workspace_root # currently unused; reserved for future config files
|
|
66
|
+
repo = self._options.get("repo")
|
|
67
|
+
if not repo:
|
|
68
|
+
raise ProviderNotConfigured(
|
|
69
|
+
"GitHubIssuesProvider requires 'repo' in [issue_provider.github_issues]. "
|
|
70
|
+
"Example: repo = \"owner/repo\"",
|
|
71
|
+
)
|
|
72
|
+
self.repo: str = repo
|
|
73
|
+
self.labels_filter: list[str] = list(self._options.get("labels_filter") or [])
|
|
74
|
+
|
|
75
|
+
# ── Protocol methods ────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def get_issue(self, alias: str) -> Issue:
|
|
78
|
+
"""Fetch a GitHub issue by alias.
|
|
79
|
+
|
|
80
|
+
Aliases:
|
|
81
|
+
- ``"#142"`` — issue 142 in the configured repo
|
|
82
|
+
- ``"142"`` — same
|
|
83
|
+
- ``"owner/repo#142"`` — explicit repo override (must match self.repo for v1; cross-repo lookup is a future)
|
|
84
|
+
"""
|
|
85
|
+
target_repo, issue_num = self._parse_alias(alias)
|
|
86
|
+
try:
|
|
87
|
+
raw = _gh_json(["api", f"repos/{target_repo}/issues/{issue_num}"])
|
|
88
|
+
except GitHubNotConfiguredError as e:
|
|
89
|
+
raise ProviderNotConfigured(str(e)) from e
|
|
90
|
+
|
|
91
|
+
if not isinstance(raw, dict) or "number" not in raw:
|
|
92
|
+
raise IssueNotFoundError(f"GitHub issue '{alias}' not found in {target_repo}")
|
|
93
|
+
# GH returns PRs from /issues/<n> too; filter them out — canopy's
|
|
94
|
+
# PR handling lives elsewhere.
|
|
95
|
+
if raw.get("pull_request"):
|
|
96
|
+
raise IssueNotFoundError(
|
|
97
|
+
f"'{alias}' is a pull request, not an issue. Use canopy review for PRs.",
|
|
98
|
+
)
|
|
99
|
+
return _to_issue(raw, default_repo=target_repo)
|
|
100
|
+
|
|
101
|
+
def list_my_issues(self, limit: int = 50) -> list[Issue]:
|
|
102
|
+
"""Return open GitHub issues assigned to the current user, scoped
|
|
103
|
+
to the configured repo. Honors ``labels_filter`` when set.
|
|
104
|
+
|
|
105
|
+
Uses ``gh issue list`` (not ``gh search issues``) — the search form
|
|
106
|
+
treats positional args as search *text*, so passing qualifiers like
|
|
107
|
+
``repo:...`` and ``is:open`` quotes them as a single text token and
|
|
108
|
+
the API returns "Invalid search query." See test-findings F-10.
|
|
109
|
+
"""
|
|
110
|
+
args = [
|
|
111
|
+
"issue", "list",
|
|
112
|
+
"--repo", self.repo,
|
|
113
|
+
"--state", "open",
|
|
114
|
+
"--assignee", "@me",
|
|
115
|
+
"--limit", str(limit),
|
|
116
|
+
"--json", "number,title,state,body,url,assignees,labels",
|
|
117
|
+
]
|
|
118
|
+
if self.labels_filter:
|
|
119
|
+
for label in self.labels_filter:
|
|
120
|
+
args.extend(["--label", label])
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
raw_list = _gh_json(args)
|
|
124
|
+
except GitHubNotConfiguredError as e:
|
|
125
|
+
raise ProviderNotConfigured(str(e)) from e
|
|
126
|
+
|
|
127
|
+
if not isinstance(raw_list, list):
|
|
128
|
+
return []
|
|
129
|
+
return [_to_issue(r, default_repo=self.repo) for r in raw_list if isinstance(r, dict)]
|
|
130
|
+
|
|
131
|
+
def format_branch_name(
|
|
132
|
+
self,
|
|
133
|
+
issue_id: str,
|
|
134
|
+
title: str | None = None,
|
|
135
|
+
custom_name: str | None = None,
|
|
136
|
+
) -> str:
|
|
137
|
+
"""``"gh-<n>-<slug>"`` or ``"gh-<n>"`` if title missing."""
|
|
138
|
+
if custom_name:
|
|
139
|
+
return custom_name
|
|
140
|
+
try:
|
|
141
|
+
_, n = self._parse_alias(issue_id)
|
|
142
|
+
except IssueProviderError:
|
|
143
|
+
# Not a parseable issue alias — fall back to lowercased id.
|
|
144
|
+
n_str = issue_id.lstrip("#").lower()
|
|
145
|
+
if not title:
|
|
146
|
+
return f"gh-{n_str}"
|
|
147
|
+
return f"gh-{n_str}-{_slugify(title)}"
|
|
148
|
+
if not title:
|
|
149
|
+
return f"gh-{n}"
|
|
150
|
+
return f"gh-{n}-{_slugify(title)}"
|
|
151
|
+
|
|
152
|
+
def update_issue_state(self, alias: str, new_state: str) -> None:
|
|
153
|
+
"""Lifecycle automation reserved for a future plan."""
|
|
154
|
+
raise NotImplementedError(
|
|
155
|
+
"GitHubIssuesProvider.update_issue_state is not implemented in v1.",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def parse_alias(self, alias: str) -> str | None:
|
|
159
|
+
"""Recognize GitHub-shaped aliases. See ``_GH_ALIAS`` shapes.
|
|
160
|
+
|
|
161
|
+
Returns the canonical alias string (which ``get_issue`` can
|
|
162
|
+
consume) when recognized, ``None`` otherwise.
|
|
163
|
+
"""
|
|
164
|
+
s = alias.strip()
|
|
165
|
+
# Full issue URL — return the issue id (provider knows its repo).
|
|
166
|
+
url_match = re.match(
|
|
167
|
+
r"^https?://github\.com/([\w\-.]+)/([\w\-.]+)/issues/(\d+)/?$", s,
|
|
168
|
+
)
|
|
169
|
+
if url_match:
|
|
170
|
+
owner, repo, num = url_match.group(1), url_match.group(2), url_match.group(3)
|
|
171
|
+
return f"{owner}/{repo}#{num}"
|
|
172
|
+
# owner/repo#N
|
|
173
|
+
if re.match(r"^[\w\-.]+/[\w\-.]+#\d+$", s):
|
|
174
|
+
return s
|
|
175
|
+
# #N or bare N
|
|
176
|
+
if re.match(r"^#?\d+$", s):
|
|
177
|
+
return s.lstrip("#")
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
# ── Internal ────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
def _parse_alias(self, alias: str) -> tuple[str, int]:
|
|
183
|
+
"""Parse an alias into (repo, issue_number).
|
|
184
|
+
|
|
185
|
+
Accepts:
|
|
186
|
+
- ``"#142"`` → (self.repo, 142)
|
|
187
|
+
- ``"142"`` → (self.repo, 142)
|
|
188
|
+
- ``"owner/repo#142"`` → ("owner/repo", 142)
|
|
189
|
+
"""
|
|
190
|
+
# owner/repo#142
|
|
191
|
+
m = re.match(r"^([\w\-.]+/[\w\-.]+)#(\d+)$", alias)
|
|
192
|
+
if m:
|
|
193
|
+
return m.group(1), int(m.group(2))
|
|
194
|
+
# #142 or 142
|
|
195
|
+
m = re.match(r"^#?(\d+)$", alias)
|
|
196
|
+
if m:
|
|
197
|
+
return self.repo, int(m.group(1))
|
|
198
|
+
raise IssueNotFoundError(
|
|
199
|
+
f"Can't parse GitHub issue alias '{alias}'. Use '#142', '142', or 'owner/repo#142'.",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── Module-level helpers ────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _gh_json(args: list[str]) -> Any:
|
|
207
|
+
"""``_gh`` wrapper that JSON-decodes stdout. Empty stdout → ``None``.
|
|
208
|
+
|
|
209
|
+
Lifted to module level so tests can monkeypatch it without touching
|
|
210
|
+
the underlying subprocess machinery in ``integrations/github.py``.
|
|
211
|
+
"""
|
|
212
|
+
out = _gh(args)
|
|
213
|
+
if not out.strip():
|
|
214
|
+
return None
|
|
215
|
+
try:
|
|
216
|
+
return json.loads(out)
|
|
217
|
+
except json.JSONDecodeError as e:
|
|
218
|
+
raise IssueProviderError(f"gh returned non-JSON output: {e}") from e
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _to_issue(raw: dict, *, default_repo: str) -> Issue:
|
|
222
|
+
"""Map a GitHub API issue payload to a canonical ``Issue``.
|
|
223
|
+
|
|
224
|
+
Used by both ``get_issue`` (which fetches a single issue) and
|
|
225
|
+
``list_my_issues`` (which gets a list from the search endpoint).
|
|
226
|
+
Search results have a slightly different shape (top-level fields are
|
|
227
|
+
similar but ``html_url`` may be missing; ``url`` is the API URL).
|
|
228
|
+
"""
|
|
229
|
+
state_raw = raw.get("state") or ""
|
|
230
|
+
state_reason = raw.get("state_reason") or ""
|
|
231
|
+
canonical_state = _GH_STATE_MAP.get(state_raw.lower(), "todo")
|
|
232
|
+
if canonical_state == "done" and state_reason == "not_planned":
|
|
233
|
+
canonical_state = "cancelled"
|
|
234
|
+
|
|
235
|
+
labels_raw = raw.get("labels") or []
|
|
236
|
+
labels: tuple[str, ...] = tuple(
|
|
237
|
+
l.get("name") if isinstance(l, dict) else str(l)
|
|
238
|
+
for l in labels_raw
|
|
239
|
+
if (isinstance(l, dict) and l.get("name")) or isinstance(l, str)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
assignees = raw.get("assignees") or []
|
|
243
|
+
assignee = (
|
|
244
|
+
assignees[0].get("login")
|
|
245
|
+
if assignees and isinstance(assignees[0], dict)
|
|
246
|
+
else (raw.get("assignee", {}) or {}).get("login")
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Search results put repo info in raw["repository"]["nameWithOwner"];
|
|
250
|
+
# single-issue responses derive from URL.
|
|
251
|
+
repository = raw.get("repository") or {}
|
|
252
|
+
if isinstance(repository, dict):
|
|
253
|
+
repo_name = repository.get("nameWithOwner") or repository.get("full_name") or default_repo
|
|
254
|
+
else:
|
|
255
|
+
repo_name = default_repo
|
|
256
|
+
|
|
257
|
+
number = raw.get("number")
|
|
258
|
+
return Issue(
|
|
259
|
+
id=str(number),
|
|
260
|
+
identifier=f"#{number}",
|
|
261
|
+
title=raw.get("title") or "",
|
|
262
|
+
description=raw.get("body"),
|
|
263
|
+
state=canonical_state,
|
|
264
|
+
url=raw.get("html_url") or raw.get("url") or _make_html_url(repo_name, number),
|
|
265
|
+
assignee=assignee,
|
|
266
|
+
labels=labels,
|
|
267
|
+
priority=_priority_from_labels(labels),
|
|
268
|
+
raw=raw,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _make_html_url(repo_name: str, number: int | None) -> str:
|
|
273
|
+
if not number:
|
|
274
|
+
return ""
|
|
275
|
+
return f"https://github.com/{repo_name}/issues/{number}"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _priority_from_labels(labels: tuple[str, ...]) -> int | None:
|
|
279
|
+
for l in labels:
|
|
280
|
+
p = _PRIORITY_LABEL_MAP.get(l.lower())
|
|
281
|
+
if p is not None:
|
|
282
|
+
return p
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _slugify(s: str) -> str:
|
|
287
|
+
s = re.sub(r"[^a-zA-Z0-9\s-]", "", s)
|
|
288
|
+
s = re.sub(r"\s+", "-", s.strip()).lower()
|
|
289
|
+
return s[:50].rstrip("-")
|