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.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -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')}"