autoforge-cli 0.1.0a0__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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/validation.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Typed GitHub URL parsing + run-argument validation.
|
|
2
|
+
|
|
3
|
+
Every module that needs owner/repo/number from a GitHub URL goes through
|
|
4
|
+
these parsers — no ad-hoc ``url.split("/")`` anywhere else.
|
|
5
|
+
|
|
6
|
+
Accepted shapes (HTTPS, ``github.com`` host only)::
|
|
7
|
+
|
|
8
|
+
https://github.com/<owner>/<repo>/issues/<number>
|
|
9
|
+
https://github.com/<owner>/<repo>/pull/<number>
|
|
10
|
+
https://github.com/<owner>/<repo>/pull/<number>#issuecomment-<id>
|
|
11
|
+
https://github.com/<owner>/<repo>/issues/<number>#issuecomment-<id>
|
|
12
|
+
|
|
13
|
+
The REST API names an issue's parent and repository by API URL instead
|
|
14
|
+
(``parent_issue_url``, ``repository_url``); :func:`parse_api_issue_url` and
|
|
15
|
+
:func:`parse_api_repository_url` read those two shapes, host
|
|
16
|
+
``api.github.com`` only::
|
|
17
|
+
|
|
18
|
+
https://api.github.com/repos/<owner>/<repo>/issues/<number>
|
|
19
|
+
https://api.github.com/repos/<owner>/<repo>
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import ClassVar
|
|
27
|
+
from urllib.parse import urlsplit
|
|
28
|
+
|
|
29
|
+
from .errors import ConfigurationError
|
|
30
|
+
|
|
31
|
+
_NAME = r"[A-Za-z0-9_.-]+"
|
|
32
|
+
_PATH_RE = re.compile(
|
|
33
|
+
rf"^/(?P<owner>{_NAME})/(?P<repo>{_NAME})/(?P<kind>issues|pull)/(?P<number>[0-9]+)/?$"
|
|
34
|
+
)
|
|
35
|
+
_COMMENT_FRAGMENT_RE = re.compile(r"^issuecomment-(?P<id>[0-9]+)$")
|
|
36
|
+
_API_REPOSITORY_RE = re.compile(rf"^/repos/(?P<owner>{_NAME})/(?P<repo>{_NAME})/?$")
|
|
37
|
+
_API_ISSUE_RE = re.compile(
|
|
38
|
+
rf"^/repos/(?P<owner>{_NAME})/(?P<repo>{_NAME})/issues/(?P<number>[0-9]+)/?$"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_EXPECTED = "expected https://github.com/<owner>/<repo>/(issues|pull)/<n>"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class GitHubRef:
|
|
46
|
+
"""Base for typed references to an issue / pull request."""
|
|
47
|
+
|
|
48
|
+
kind: ClassVar[str] = ""
|
|
49
|
+
_path_segment: ClassVar[str] = ""
|
|
50
|
+
|
|
51
|
+
owner: str
|
|
52
|
+
repo: str
|
|
53
|
+
number: int
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def repository(self) -> str:
|
|
57
|
+
return f"{self.owner}/{self.repo}"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def canonical(self) -> str:
|
|
61
|
+
return f"https://github.com/{self.owner}/{self.repo}/{self._path_segment}/{self.number}"
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def identity(self) -> tuple[str, str, str, int]:
|
|
65
|
+
"""The issue / PR as GitHub identifies it: kind, owner, repo, number.
|
|
66
|
+
|
|
67
|
+
Owner and repository are folded to lower case because GitHub treats
|
|
68
|
+
them case-insensitively. This is the one definition of identity:
|
|
69
|
+
:meth:`same_target` compares it, and a set or dict keyed by it (a
|
|
70
|
+
snapshot of PRs, a membership check) applies the same rule as a
|
|
71
|
+
pairwise comparison, so the two can never drift.
|
|
72
|
+
"""
|
|
73
|
+
return (self.kind, self.owner.lower(), self.repo.lower(), self.number)
|
|
74
|
+
|
|
75
|
+
def same_repository(self, other: GitHubRef | str) -> bool:
|
|
76
|
+
other_repo = other if isinstance(other, str) else other.repository
|
|
77
|
+
return self.repository.lower() == other_repo.strip().lower()
|
|
78
|
+
|
|
79
|
+
def same_target(self, other: GitHubRef) -> bool:
|
|
80
|
+
"""Whether both refs name the same issue / PR.
|
|
81
|
+
|
|
82
|
+
GitHub owner and repository names are case-insensitive, so
|
|
83
|
+
``.../Owner/Repo/issues/23`` and ``.../owner/repo/issues/23`` are the
|
|
84
|
+
same issue although their canonical URLs differ. Identity checks must
|
|
85
|
+
use this (or :attr:`identity`), never a string comparison of URLs.
|
|
86
|
+
"""
|
|
87
|
+
return self.identity == other.identity
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class GitHubIssueRef(GitHubRef):
|
|
92
|
+
kind: ClassVar[str] = "issue"
|
|
93
|
+
_path_segment: ClassVar[str] = "issues"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class GitHubPullRequestRef(GitHubRef):
|
|
98
|
+
kind: ClassVar[str] = "pr"
|
|
99
|
+
_path_segment: ClassVar[str] = "pull"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class GitHubCommentRef:
|
|
104
|
+
"""An issue-style comment (``#issuecomment-<id>``) on an issue or PR.
|
|
105
|
+
|
|
106
|
+
GitHub serves one such comment under both paths of its parent: a pull
|
|
107
|
+
request is an issue to the comments API, issues and pull requests share
|
|
108
|
+
one number space, and ``.../issues/<n>#issuecomment-<id>`` and
|
|
109
|
+
``.../pull/<n>#issuecomment-<id>`` open the same comment. The parent's
|
|
110
|
+
path kind is therefore part of the spelling, not of the comment's
|
|
111
|
+
identity: :attr:`identity` and :meth:`same_target` compare the
|
|
112
|
+
repository (case-folded), the parent number and the comment id, and
|
|
113
|
+
:meth:`on` says whether the comment is on a given issue or PR under
|
|
114
|
+
either spelling. The kind the URL used is still kept in :attr:`parent`
|
|
115
|
+
and rendered by :attr:`canonical`.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
parent: GitHubIssueRef | GitHubPullRequestRef
|
|
119
|
+
comment_id: int
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def repository(self) -> str:
|
|
123
|
+
return self.parent.repository
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def canonical(self) -> str:
|
|
127
|
+
return f"{self.parent.canonical}#issuecomment-{self.comment_id}"
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def identity(self) -> tuple[str, str, int, int]:
|
|
131
|
+
"""The comment as GitHub identifies it: owner, repo, parent number, comment id."""
|
|
132
|
+
return (
|
|
133
|
+
self.parent.owner.lower(),
|
|
134
|
+
self.parent.repo.lower(),
|
|
135
|
+
self.parent.number,
|
|
136
|
+
self.comment_id,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def same_target(self, other: GitHubCommentRef) -> bool:
|
|
140
|
+
"""Same comment as GitHub sees it, whichever parent path either URL used."""
|
|
141
|
+
return self.identity == other.identity
|
|
142
|
+
|
|
143
|
+
def on(self, target: GitHubRef) -> bool:
|
|
144
|
+
"""Whether this comment is on ``target``, under either of GitHub's paths.
|
|
145
|
+
|
|
146
|
+
A reviewer naming a PR comment by its ``issues/<n>`` URL names this
|
|
147
|
+
PR's comment; a comment on another number or repository does not.
|
|
148
|
+
"""
|
|
149
|
+
return self.parent.same_repository(target) and self.parent.number == target.number
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Backwards-compatible alias used by Phase-1 callers/tests.
|
|
153
|
+
ParsedURL = GitHubRef
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def same_issue_url(observed: str, expected: str) -> bool:
|
|
157
|
+
"""Issue identity as GitHub sees it, never vacuous: an unusable URL matches nothing.
|
|
158
|
+
|
|
159
|
+
Agent-claimed and persisted URLs are compared with this, never by string
|
|
160
|
+
equality of their canonical forms: the canonical form keeps the owner and
|
|
161
|
+
repository spelling its author used, and GitHub treats ``Owner/Repo`` and
|
|
162
|
+
``owner/repo`` as one repository (see :meth:`GitHubRef.same_target`).
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
return parse_issue_url(observed).same_target(parse_issue_url(expected))
|
|
166
|
+
except ConfigurationError:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def same_pr_url(observed: str, expected: str) -> bool:
|
|
171
|
+
"""PR identity as GitHub sees it (see :func:`same_issue_url`)."""
|
|
172
|
+
try:
|
|
173
|
+
return parse_pr_url(observed).same_target(parse_pr_url(expected))
|
|
174
|
+
except ConfigurationError:
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _split(url: str):
|
|
179
|
+
if not isinstance(url, str) or not url.strip():
|
|
180
|
+
raise ConfigurationError(f"not a valid HTTPS GitHub URL: {url!r} ({_EXPECTED})")
|
|
181
|
+
try:
|
|
182
|
+
parts = urlsplit(url.strip())
|
|
183
|
+
except ValueError as exc: # an unbalanced or invalid bracketed host, an NFKC netloc
|
|
184
|
+
raise ConfigurationError(f"not a valid HTTPS GitHub URL: {url!r} ({exc})") from None
|
|
185
|
+
if parts.scheme != "https":
|
|
186
|
+
raise ConfigurationError(
|
|
187
|
+
f"GitHub URL must use https, got {parts.scheme or 'no'} scheme: {url!r}"
|
|
188
|
+
)
|
|
189
|
+
if parts.netloc.lower() != "github.com":
|
|
190
|
+
raise ConfigurationError(f"GitHub URL host must be github.com: {url!r}")
|
|
191
|
+
if parts.query:
|
|
192
|
+
raise ConfigurationError(f"GitHub URL must not carry a query string: {url!r}")
|
|
193
|
+
return parts
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _number(digits: str, what: str, url: str) -> int:
|
|
197
|
+
"""``digits`` (ASCII, matched by a pattern) as an int; too many of them are malformed."""
|
|
198
|
+
try:
|
|
199
|
+
return int(digits)
|
|
200
|
+
except ValueError: # over the interpreter's integer string conversion limit
|
|
201
|
+
raise ConfigurationError(
|
|
202
|
+
f"GitHub {what} has {len(digits)} digits, too many for a number: {url!r}"
|
|
203
|
+
) from None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def parse_github_url(url: str, expect: str | None = None) -> GitHubIssueRef | GitHubPullRequestRef:
|
|
207
|
+
"""Parse + validate an issue or PR URL into a typed reference.
|
|
208
|
+
|
|
209
|
+
``expect`` optionally constrains to ``"issue"`` or ``"pr"``. Any
|
|
210
|
+
malformed input raises ConfigurationError with a precise reason.
|
|
211
|
+
"""
|
|
212
|
+
parts = _split(url)
|
|
213
|
+
if parts.fragment:
|
|
214
|
+
raise ConfigurationError(f"unexpected fragment in issue/PR URL: {url!r}")
|
|
215
|
+
m = _PATH_RE.match(parts.path)
|
|
216
|
+
if not m:
|
|
217
|
+
raise ConfigurationError(f"malformed GitHub URL: {url!r} ({_EXPECTED})")
|
|
218
|
+
number = _number(m.group("number"), "issue/PR number", url)
|
|
219
|
+
if number < 1:
|
|
220
|
+
raise ConfigurationError(f"GitHub issue/PR number must be >= 1: {url!r}")
|
|
221
|
+
ref: GitHubIssueRef | GitHubPullRequestRef
|
|
222
|
+
if m.group("kind") == "issues":
|
|
223
|
+
ref = GitHubIssueRef(owner=m.group("owner"), repo=m.group("repo"), number=number)
|
|
224
|
+
else:
|
|
225
|
+
ref = GitHubPullRequestRef(owner=m.group("owner"), repo=m.group("repo"), number=number)
|
|
226
|
+
if expect and ref.kind != expect:
|
|
227
|
+
raise ConfigurationError(f"expected a GitHub {expect} URL, got {ref.kind}: {url!r}")
|
|
228
|
+
return ref
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def parse_issue_url(url: str) -> GitHubIssueRef:
|
|
232
|
+
ref = parse_github_url(url, expect="issue")
|
|
233
|
+
assert isinstance(ref, GitHubIssueRef)
|
|
234
|
+
return ref
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def parse_pr_url(url: str) -> GitHubPullRequestRef:
|
|
238
|
+
ref = parse_github_url(url, expect="pr")
|
|
239
|
+
assert isinstance(ref, GitHubPullRequestRef)
|
|
240
|
+
return ref
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def parse_comment_url(url: str) -> GitHubCommentRef:
|
|
244
|
+
"""Parse ``.../pull/<n>#issuecomment-<id>`` (or the issues variant)."""
|
|
245
|
+
parts = _split(url)
|
|
246
|
+
m = _PATH_RE.match(parts.path)
|
|
247
|
+
if not m:
|
|
248
|
+
raise ConfigurationError(f"malformed GitHub comment URL: {url!r}")
|
|
249
|
+
fm = _COMMENT_FRAGMENT_RE.match(parts.fragment or "")
|
|
250
|
+
if not fm:
|
|
251
|
+
raise ConfigurationError(f"GitHub comment URL must end with '#issuecomment-<id>': {url!r}")
|
|
252
|
+
parent = parse_github_url(url.split("#", 1)[0])
|
|
253
|
+
return GitHubCommentRef(parent=parent, comment_id=_number(fm.group("id"), "comment id", url))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _split_api(url: str):
|
|
257
|
+
"""``url`` as an ``https://api.github.com`` URL with no query or fragment."""
|
|
258
|
+
if not isinstance(url, str) or not url.strip():
|
|
259
|
+
raise ConfigurationError(f"not a valid GitHub API URL: {url!r}")
|
|
260
|
+
try:
|
|
261
|
+
parts = urlsplit(url.strip())
|
|
262
|
+
except ValueError as exc:
|
|
263
|
+
raise ConfigurationError(f"not a valid GitHub API URL: {url!r} ({exc})") from None
|
|
264
|
+
if parts.scheme != "https" or parts.netloc.lower() != "api.github.com":
|
|
265
|
+
raise ConfigurationError(f"GitHub API URL must be https://api.github.com/...: {url!r}")
|
|
266
|
+
if parts.query or parts.fragment:
|
|
267
|
+
raise ConfigurationError(f"GitHub API URL must not carry a query or fragment: {url!r}")
|
|
268
|
+
return parts
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def parse_api_issue_url(url: str) -> GitHubIssueRef:
|
|
272
|
+
"""The issue an API URL (``.../repos/<owner>/<repo>/issues/<n>``) names.
|
|
273
|
+
|
|
274
|
+
The REST spelling of an issue reference, such as ``parent_issue_url``;
|
|
275
|
+
the result is the same typed reference :func:`parse_issue_url` returns
|
|
276
|
+
for the issue's HTML URL, so the two compare with ``same_target``.
|
|
277
|
+
"""
|
|
278
|
+
m = _API_ISSUE_RE.match(_split_api(url).path)
|
|
279
|
+
if not m:
|
|
280
|
+
raise ConfigurationError(f"malformed GitHub API issue URL: {url!r}")
|
|
281
|
+
number = _number(m.group("number"), "issue number", url)
|
|
282
|
+
if number < 1:
|
|
283
|
+
raise ConfigurationError(f"GitHub issue number must be >= 1: {url!r}")
|
|
284
|
+
return GitHubIssueRef(owner=m.group("owner"), repo=m.group("repo"), number=number)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def parse_api_repository_url(url: str) -> str:
|
|
288
|
+
"""``owner/repo`` of an API repository URL (``.../repos/<owner>/<repo>``)."""
|
|
289
|
+
m = _API_REPOSITORY_RE.match(_split_api(url).path)
|
|
290
|
+
if not m:
|
|
291
|
+
raise ConfigurationError(f"malformed GitHub API repository URL: {url!r}")
|
|
292
|
+
return f"{m.group('owner')}/{m.group('repo')}"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def validate_epic_and_issue(epic_url: str, issue_url: str) -> tuple[GitHubIssueRef, GitHubIssueRef]:
|
|
296
|
+
"""Validate run URLs; epic and issue must live in the same repository."""
|
|
297
|
+
epic = parse_issue_url(epic_url)
|
|
298
|
+
issue = parse_issue_url(issue_url)
|
|
299
|
+
if not epic.same_repository(issue):
|
|
300
|
+
raise ConfigurationError(
|
|
301
|
+
f"EPIC repo {epic.repository!r} != issue repo {issue.repository!r}; "
|
|
302
|
+
"cross-repository runs are not supported"
|
|
303
|
+
)
|
|
304
|
+
return epic, issue
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
_REMOTE_HTTPS_RE = re.compile(
|
|
308
|
+
r"^(?:https?://|ssh://git@)github\.com[/:](?P<owner>[A-Za-z0-9_.-]+)/(?P<repo>[A-Za-z0-9_.-]+?)(?:\.git)?/?$"
|
|
309
|
+
)
|
|
310
|
+
_REMOTE_SCP_RE = re.compile(
|
|
311
|
+
r"^git@github\.com:(?P<owner>[A-Za-z0-9_.-]+)/(?P<repo>[A-Za-z0-9_.-]+?)(?:\.git)?$"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def parse_remote_repository(remote: str) -> str:
|
|
316
|
+
"""Return ``owner/repo`` for a GitHub remote URL (https, ssh://, or scp-like).
|
|
317
|
+
|
|
318
|
+
Raises ConfigurationError for non-GitHub or malformed remotes.
|
|
319
|
+
"""
|
|
320
|
+
text = (remote or "").strip()
|
|
321
|
+
m = _REMOTE_HTTPS_RE.match(text) or _REMOTE_SCP_RE.match(text)
|
|
322
|
+
if not m:
|
|
323
|
+
raise ConfigurationError(f"remote {remote!r} is not a recognizable github.com remote")
|
|
324
|
+
return f"{m.group('owner')}/{m.group('repo')}"
|