gitfence 0.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.
Files changed (59) hide show
  1. fence/__init__.py +3 -0
  2. fence/anchors/__init__.py +0 -0
  3. fence/anchors/fingerprint.py +69 -0
  4. fence/check/__init__.py +0 -0
  5. fence/check/classify.py +84 -0
  6. fence/check/diff.py +127 -0
  7. fence/check/engine.py +263 -0
  8. fence/cli.py +576 -0
  9. fence/config.py +209 -0
  10. fence/context.py +77 -0
  11. fence/decisions/__init__.py +0 -0
  12. fence/decisions/build.py +579 -0
  13. fence/decisions/model.py +117 -0
  14. fence/decisions/obsolete.py +121 -0
  15. fence/decisions/reasons.py +150 -0
  16. fence/decisions/relevance.py +146 -0
  17. fence/diffing/__init__.py +0 -0
  18. fence/diffing/ranges.py +25 -0
  19. fence/diffing/symbols.py +21 -0
  20. fence/eval/__init__.py +0 -0
  21. fence/eval/backtest.py +271 -0
  22. fence/git/__init__.py +0 -0
  23. fence/git/blame.py +219 -0
  24. fence/git/history.py +171 -0
  25. fence/git/log.py +60 -0
  26. fence/git/runner.py +248 -0
  27. fence/github/__init__.py +0 -0
  28. fence/github/client.py +122 -0
  29. fence/github/comment.py +50 -0
  30. fence/github/sync.py +123 -0
  31. fence/globs.py +43 -0
  32. fence/index/__init__.py +0 -0
  33. fence/index/formatting.py +53 -0
  34. fence/index/ghosts.py +143 -0
  35. fence/index/indexer.py +219 -0
  36. fence/index/store.py +389 -0
  37. fence/lang/__init__.py +118 -0
  38. fence/lang/python.py +67 -0
  39. fence/lang/treesitter.py +165 -0
  40. fence/mcp/__init__.py +0 -0
  41. fence/mcp/server.py +306 -0
  42. fence/policy/__init__.py +0 -0
  43. fence/policy/acks.py +88 -0
  44. fence/render.py +287 -0
  45. fence/report.py +103 -0
  46. fence/scan.py +50 -0
  47. fence/scoring/__init__.py +0 -0
  48. fence/scoring/calibration.py +133 -0
  49. fence/scoring/strength.py +198 -0
  50. fence/signals/__init__.py +0 -0
  51. fence/signals/catalog.py +161 -0
  52. fence/signals/code.py +69 -0
  53. fence/text.py +43 -0
  54. fence/trace.py +65 -0
  55. gitfence-0.1.0.dist-info/METADATA +389 -0
  56. gitfence-0.1.0.dist-info/RECORD +59 -0
  57. gitfence-0.1.0.dist-info/WHEEL +4 -0
  58. gitfence-0.1.0.dist-info/entry_points.txt +3 -0
  59. gitfence-0.1.0.dist-info/licenses/LICENSE +21 -0
fence/git/log.py ADDED
@@ -0,0 +1,60 @@
1
+ """`git log -L` for walking further back than blame when a commit explains nothing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from fence.git.runner import GitError, Repo, UnsafeInput
8
+
9
+ DEFAULT_MAX_COUNT = 20
10
+ MAX_COUNT_LIMIT = 200
11
+
12
+ _FIELD = "\x1f"
13
+ _RECORD = "\x1e"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class LogCommit:
18
+ sha: str
19
+ author: str
20
+ author_mail: str
21
+ date: str # strict ISO 8601 author date
22
+ subject: str
23
+
24
+
25
+ def log_line_range(
26
+ repo: Repo, rev: str, path: str, start: int, end: int, *, max_count: int = DEFAULT_MAX_COUNT
27
+ ) -> list[LogCommit]:
28
+ """Commits that touched lines `start`..`end` of `path` as of `rev`, newest first."""
29
+ sha = repo.verify_ref(rev)
30
+ rel = repo.safe_relpath(path)
31
+ if start < 1 or end < start:
32
+ raise UnsafeInput(f"bad line range: {start},{end}")
33
+ if not 1 <= max_count <= MAX_COUNT_LIMIT:
34
+ raise UnsafeInput(f"max_count must be 1..{MAX_COUNT_LIMIT}")
35
+ out = repo.run(
36
+ [
37
+ "log",
38
+ f"-L{start},{end}:{rel}",
39
+ "-s", # metadata only; the patch text is never needed here
40
+ "--no-color",
41
+ "--no-ext-diff",
42
+ f"--max-count={max_count}",
43
+ f"--format=%H{_FIELD}%an{_FIELD}%ae{_FIELD}%aI{_FIELD}%s{_RECORD}",
44
+ sha,
45
+ ]
46
+ )
47
+ return parse_log(out)
48
+
49
+
50
+ def parse_log(output: str) -> list[LogCommit]:
51
+ commits: list[LogCommit] = []
52
+ for record in output.split(_RECORD):
53
+ record = record.strip("\n")
54
+ if not record:
55
+ continue
56
+ parts = record.split(_FIELD)
57
+ if len(parts) != 5 or len(parts[0]) not in (40, 64):
58
+ raise GitError(f"unexpected git log record: {record[:80]!r}")
59
+ commits.append(LogCommit(*parts))
60
+ return commits
fence/git/runner.py ADDED
@@ -0,0 +1,248 @@
1
+ """The only place Fence starts a git process.
2
+
3
+ Every argument that comes from outside (a ref, a path) is untrusted: it may come
4
+ from a diff, a CLI argument, or later from model output. So:
5
+ - arguments are always a list, never a shell string;
6
+ - refs are syntax-checked, then resolved by git itself to a commit SHA;
7
+ - paths must stay inside the repo root after resolving symlinks;
8
+ - every call has a timeout.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import codecs
14
+ import os
15
+ import re
16
+ import subprocess
17
+ import tempfile
18
+ import threading
19
+ from collections.abc import Iterator, Sequence
20
+ from dataclasses import dataclass
21
+ from pathlib import Path, PurePosixPath
22
+
23
+ DEFAULT_TIMEOUT_SECONDS = 10.0
24
+ MAX_STDERR_CHARS = 500
25
+ _VERIFIED: set[tuple[str, str]] = set()
26
+
27
+ # Characters git allows in refs that we also accept: names, SHAs, HEAD~2, main^, origin/main.
28
+ _REF_PATTERN = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._/~^@{}-]*$")
29
+
30
+
31
+ class GitError(Exception):
32
+ """git failed, or Fence refused to run it."""
33
+
34
+
35
+ class GitTimeout(GitError):
36
+ """git did not finish within the timeout."""
37
+
38
+
39
+ class UnsafeInput(GitError):
40
+ """A ref or path was rejected before reaching git."""
41
+
42
+
43
+ def _git_env() -> dict[str, str]:
44
+ env = dict(os.environ)
45
+ env.update(
46
+ {
47
+ "GIT_TERMINAL_PROMPT": "0", # never block on a credential prompt
48
+ "GIT_PAGER": "cat",
49
+ "GIT_OPTIONAL_LOCKS": "0", # read-only: don't take index locks
50
+ "LC_ALL": "C", # stable, parseable output
51
+ }
52
+ )
53
+ return env
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Repo:
58
+ root: Path
59
+ timeout: float = DEFAULT_TIMEOUT_SECONDS
60
+
61
+ @classmethod
62
+ def open(cls, path: str | Path, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> Repo:
63
+ start = Path(path).resolve()
64
+ if not start.is_dir():
65
+ raise GitError(f"not a directory: {start}")
66
+ top = _run(["rev-parse", "--show-toplevel"], cwd=start, timeout=timeout).strip()
67
+ return cls(root=Path(top).resolve(), timeout=timeout)
68
+
69
+ def run(self, args: Sequence[str], *, timeout: float | None = None) -> str:
70
+ """Run `git <args>` in the repo root and return stdout."""
71
+ return _run(args, cwd=self.root, timeout=timeout or self.timeout)
72
+
73
+ def verify_ref(self, ref: str) -> str:
74
+ """Return the full commit SHA for `ref`, or raise UnsafeInput / GitError."""
75
+ if not ref or not _REF_PATTERN.match(ref) or ".." in ref:
76
+ raise UnsafeInput(f"rejected ref: {ref!r}")
77
+ # A full SHA that resolved to a commit once always will: memoize those only.
78
+ key = (str(self.root), ref)
79
+ if key in _VERIFIED:
80
+ return ref
81
+ out = self.run(
82
+ ["rev-parse", "--verify", "--quiet", "--end-of-options", f"{ref}^{{commit}}"]
83
+ )
84
+ sha = out.strip()
85
+ if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", sha):
86
+ raise GitError(f"could not resolve ref: {ref!r}")
87
+ if sha == ref and len(_VERIFIED) < 100_000:
88
+ _VERIFIED.add(key)
89
+ return sha
90
+
91
+ def safe_relpath(self, path: str | Path) -> str:
92
+ """Return `path` as a repo-relative POSIX path, refusing anything that escapes the root.
93
+
94
+ Works for files that no longer exist in the working tree (deleted in a diff),
95
+ but any symlink that does exist on the way is resolved and checked.
96
+ """
97
+ raw = str(path)
98
+ if not raw or "\x00" in raw or raw.startswith("-"):
99
+ raise UnsafeInput(f"rejected path: {raw!r}")
100
+ candidate = Path(raw)
101
+ if not candidate.is_absolute():
102
+ candidate = self.root / candidate
103
+ resolved = candidate.resolve(strict=False)
104
+ try:
105
+ rel = resolved.relative_to(self.root)
106
+ except ValueError:
107
+ raise UnsafeInput(f"path escapes repo root: {raw!r}") from None
108
+ rel_posix = PurePosixPath(*rel.parts)
109
+ if str(rel_posix) in ("", "."):
110
+ raise UnsafeInput(f"path is the repo root: {raw!r}")
111
+ return str(rel_posix)
112
+
113
+ def read_blob(self, ref: str, path: str, *, max_bytes: int = 1_000_000) -> str:
114
+ """Return the contents of `path` at `ref` (`git show ref:path`)."""
115
+ sha = self.verify_ref(ref)
116
+ rel = self.safe_relpath(path)
117
+ # Check the size before reading, so a huge blob in a hostile repo is never loaded.
118
+ size = self.run(["cat-file", "-s", f"{sha}:{rel}"]).strip()
119
+ if not size.isdigit() or int(size) > max_bytes:
120
+ raise GitError(f"file too large to read: {rel} ({size} bytes)")
121
+ return self.run(["show", f"{sha}:{rel}"])
122
+
123
+ def read_blobs(
124
+ self, specs: Sequence[tuple[str, str]], *, max_bytes: int = 1_000_000
125
+ ) -> dict[tuple[str, str], str | None]:
126
+ """Read many `(commit_sha, path)` blobs with two git processes total.
127
+
128
+ Missing, binary-looking or oversized blobs map to None. Sizes are checked with
129
+ --batch-check first, so oversized content is never transferred.
130
+ """
131
+ ok = [
132
+ (sha, path) for sha, path in dict.fromkeys(specs)
133
+ if re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", sha)
134
+ and path and "\n" not in path and "\r" not in path
135
+ ]
136
+ result: dict[tuple[str, str], str | None] = {s: None for s in specs}
137
+ if not ok:
138
+ return result
139
+ query = "".join(f"{sha}:{path}\n" for sha, path in ok).encode("utf-8")
140
+ checks = _run_bytes(["cat-file", "--batch-check"], cwd=self.root, timeout=self.timeout,
141
+ input=query).decode("utf-8", "replace").split("\n")
142
+ small = []
143
+ for spec, line in zip(ok, checks, strict=False):
144
+ parts = line.split(" ")
145
+ if len(parts) == 3 and parts[1] == "blob" and parts[2].isdigit():
146
+ if int(parts[2]) <= max_bytes:
147
+ small.append(spec)
148
+ if not small:
149
+ return result
150
+ query = "".join(f"{sha}:{path}\n" for sha, path in small).encode("utf-8")
151
+ out = _run_bytes(["cat-file", "--batch"], cwd=self.root, timeout=self.timeout,
152
+ input=query)
153
+ pos = 0
154
+ for spec in small:
155
+ nl = out.index(b"\n", pos)
156
+ header = out[pos:nl].decode("utf-8", "replace").split(" ")
157
+ pos = nl + 1
158
+ if len(header) != 3 or not header[2].isdigit():
159
+ continue
160
+ size = int(header[2])
161
+ data = out[pos : pos + size]
162
+ pos += size + 1 # content is followed by a newline
163
+ if b"\x00" not in data[:8000]:
164
+ result[spec] = data.decode("utf-8", "replace")
165
+ return result
166
+
167
+ def stream(
168
+ self, args: Sequence[str], *, timeout: float | None = None, chunk_size: int = 65536
169
+ ) -> Iterator[str]:
170
+ """Run `git <args>` and yield stdout in text chunks, for output too big to hold.
171
+
172
+ `timeout` bounds the whole run. Stopping iteration early kills git.
173
+ """
174
+ return _stream(args, cwd=self.root, timeout=timeout or self.timeout, chunk_size=chunk_size)
175
+
176
+
177
+ def _stream(args: Sequence[str], *, cwd: Path, timeout: float, chunk_size: int) -> Iterator[str]:
178
+ if isinstance(args, str):
179
+ raise TypeError("git args must be a list, not a string")
180
+ argv = ["git", "-c", "core.fsmonitor=false", "-c", "core.quotePath=false", *args]
181
+ # stderr goes to a file: a PIPE nobody reads can fill up and deadlock git.
182
+ with tempfile.TemporaryFile() as err:
183
+ try:
184
+ proc = subprocess.Popen(
185
+ argv, cwd=cwd, env=_git_env(), stdout=subprocess.PIPE, stderr=err, shell=False
186
+ )
187
+ except FileNotFoundError:
188
+ raise GitError("git executable not found") from None
189
+ timed_out = threading.Event()
190
+
191
+ def _kill() -> None:
192
+ timed_out.set()
193
+ proc.kill()
194
+
195
+ timer = threading.Timer(timeout, _kill)
196
+ timer.daemon = True
197
+ timer.start()
198
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
199
+ try:
200
+ assert proc.stdout is not None # noqa: S101 - set by stdout=PIPE
201
+ while data := proc.stdout.read1(chunk_size):
202
+ yield decoder.decode(data)
203
+ tail = decoder.decode(b"", final=True)
204
+ if tail:
205
+ yield tail
206
+ returncode = proc.wait()
207
+ finally:
208
+ timer.cancel()
209
+ if proc.poll() is None:
210
+ proc.kill()
211
+ proc.wait()
212
+ if proc.stdout is not None:
213
+ proc.stdout.close()
214
+ if timed_out.is_set():
215
+ raise GitTimeout(f"git {args[0] if args else ''} timed out after {timeout}s")
216
+ if returncode != 0:
217
+ err.seek(0)
218
+ stderr = err.read(MAX_STDERR_CHARS * 4).decode("utf-8", "replace").strip()
219
+ raise GitError(
220
+ f"git {args[0] if args else ''} failed ({returncode}): {stderr[:MAX_STDERR_CHARS]}"
221
+ )
222
+
223
+
224
+ def _run_bytes(
225
+ args: Sequence[str], *, cwd: Path, timeout: float, input: bytes | None = None
226
+ ) -> bytes:
227
+ if isinstance(args, str):
228
+ raise TypeError("git args must be a list, not a string")
229
+ argv = ["git", "-c", "core.fsmonitor=false", "-c", "core.quotePath=false", *args]
230
+ try:
231
+ proc = subprocess.run(
232
+ argv, cwd=cwd, env=_git_env(), input=input, capture_output=True, timeout=timeout,
233
+ check=False, shell=False,
234
+ )
235
+ except subprocess.TimeoutExpired:
236
+ raise GitTimeout(f"git {args[0] if args else ''} timed out after {timeout}s") from None
237
+ except FileNotFoundError:
238
+ raise GitError("git executable not found") from None
239
+ if proc.returncode != 0:
240
+ stderr = proc.stderr.decode("utf-8", "replace").strip()[:MAX_STDERR_CHARS]
241
+ raise GitError(f"git {args[0] if args else ''} failed ({proc.returncode}): {stderr}")
242
+ return proc.stdout
243
+
244
+
245
+ def _run(args: Sequence[str], *, cwd: Path, timeout: float) -> str:
246
+ # Bytes in, decoded here: text mode would translate CRLF to LF and hand back
247
+ # file content that differs from the blob (and from any diff of it).
248
+ return _run_bytes(args, cwd=cwd, timeout=timeout).decode("utf-8", errors="replace")
File without changes
fence/github/client.py ADDED
@@ -0,0 +1,122 @@
1
+ """Minimal GitHub REST client: ETag revalidation, rate-limit respect, pagination.
2
+
3
+ Stdlib only. The token is sent only to the configured https base URL and is
4
+ never logged: every error message passes through `_redact`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+ from dataclasses import dataclass
15
+
16
+ from fence.index.store import Store
17
+
18
+ MAX_BODY = 2_000_000
19
+ _LINK_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"')
20
+
21
+
22
+ class GithubError(Exception):
23
+ pass
24
+
25
+
26
+ class RateLimited(GithubError):
27
+ pass
28
+
29
+
30
+ @dataclass
31
+ class Response:
32
+ status: int
33
+ data: object
34
+ next_url: str | None
35
+
36
+
37
+ class Client:
38
+ def __init__(self, base_url: str, token: str | None, store: Store, *, min_remaining: int = 50,
39
+ policy: str = "stop", max_requests: int = 2000, timeout: float = 20.0,
40
+ opener=None) -> None:
41
+ self.base = base_url.rstrip("/")
42
+ self.token = token
43
+ self.store = store
44
+ self.min_remaining = min_remaining
45
+ self.policy = policy
46
+ self.max_requests = max_requests
47
+ self.timeout = timeout
48
+ self.requests = 0
49
+ self.remaining: int | None = None
50
+ self._open = opener or urllib.request.urlopen
51
+
52
+ def _redact(self, text: str) -> str:
53
+ return text.replace(self.token, "***") if self.token else text
54
+
55
+ def get(self, path_or_url: str) -> Response:
56
+ url = path_or_url if path_or_url.startswith(self.base) else self.base + path_or_url
57
+ if not url.startswith(self.base + "/"):
58
+ raise GithubError("refusing to send the token to another host")
59
+ if self.requests >= self.max_requests:
60
+ raise RateLimited(f"request budget of {self.max_requests} per run used up")
61
+ if self.remaining is not None and self.remaining <= self.min_remaining:
62
+ raise RateLimited(f"GitHub rate limit nearly exhausted ({self.remaining} left)")
63
+ cached = self.store.conn.execute(
64
+ "SELECT etag, body FROM http_cache WHERE url = ?", (url,)).fetchone()
65
+ req = urllib.request.Request(url, headers={ # noqa: S310 - base is validated https
66
+ "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28",
67
+ "User-Agent": "fence",
68
+ **({"Authorization": f"Bearer {self.token}"} if self.token else {}),
69
+ **({"If-None-Match": cached["etag"]} if cached and cached["etag"] else {}),
70
+ })
71
+ self.requests += 1
72
+ try:
73
+ with self._open(req, timeout=self.timeout) as resp: # noqa: S310 - https only
74
+ self._note_limits(resp.headers)
75
+ body = resp.read(MAX_BODY + 1)
76
+ if len(body) > MAX_BODY:
77
+ raise GithubError(f"response too large: {url}")
78
+ etag = resp.headers.get("ETag")
79
+ text = body.decode("utf-8", "replace")
80
+ with self.store.transaction():
81
+ self.store.conn.execute(
82
+ "INSERT OR REPLACE INTO http_cache VALUES(?,?,?,?)",
83
+ (url, etag, text, int(time.time())))
84
+ return Response(resp.status, json.loads(text), _next(resp.headers))
85
+ except urllib.error.HTTPError as e:
86
+ self._note_limits(e.headers)
87
+ if e.code == 304 and cached:
88
+ return Response(200, json.loads(cached["body"]), _next(e.headers))
89
+ if e.code in (403, 429) and (e.headers.get("X-RateLimit-Remaining") == "0"
90
+ or e.code == 429):
91
+ if self.policy == "wait":
92
+ reset = int(e.headers.get("X-RateLimit-Reset") or 0)
93
+ delay = max(1, min(900, reset - int(time.time())))
94
+ time.sleep(delay)
95
+ return self.get(url)
96
+ raise RateLimited("GitHub rate limit reached; re-run later to resume") from None
97
+ return Response(e.code, None, None)
98
+ except (urllib.error.URLError, TimeoutError, OSError) as e:
99
+ raise GithubError(self._redact(f"GitHub unreachable: {e}")) from None
100
+ except ValueError:
101
+ raise GithubError(f"invalid JSON from {url}") from None
102
+
103
+ def paginate(self, path: str, limit: int = 300) -> list:
104
+ out: list = []
105
+ url: str | None = path + ("&" if "?" in path else "?") + "per_page=100"
106
+ while url and len(out) < limit:
107
+ r = self.get(url)
108
+ if r.status != 200 or not isinstance(r.data, list):
109
+ break
110
+ out += r.data
111
+ url = r.next_url
112
+ return out[:limit]
113
+
114
+ def _note_limits(self, headers) -> None:
115
+ rem = headers.get("X-RateLimit-Remaining") if headers else None
116
+ if rem and rem.isdigit():
117
+ self.remaining = int(rem)
118
+
119
+
120
+ def _next(headers) -> str | None:
121
+ m = _LINK_NEXT.search(headers.get("Link", "") if headers else "")
122
+ return m.group(1) if m else None
@@ -0,0 +1,50 @@
1
+ """One sticky PR comment, found by a hidden marker and updated in place.
2
+
3
+ Fork PRs get a read-only token: posting then fails with 403, and the caller falls
4
+ back to the job summary. Fence never asks for pull_request_target.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import urllib.error
11
+ import urllib.request
12
+
13
+ from fence.render import COMMENT_MARKER
14
+
15
+ MAX_COMMENT = 60_000
16
+
17
+
18
+ def upsert(api: str, repo: str, pr: int, body: str, token: str, *, opener=None) -> str:
19
+ """Create or update the sticky comment. Returns 'created', 'updated' or 'forbidden'."""
20
+ if not api.startswith("https://"):
21
+ raise ValueError("api base must be https")
22
+ opener = opener or urllib.request.urlopen
23
+ body = body[:MAX_COMMENT]
24
+
25
+ def call(method: str, url: str, data: dict | None = None):
26
+ req = urllib.request.Request( # noqa: S310 - https only, checked above
27
+ url, method=method, data=json.dumps(data).encode() if data is not None else None,
28
+ headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json",
29
+ "User-Agent": "fence", "Content-Type": "application/json"})
30
+ with opener(req, timeout=20) as resp:
31
+ return json.loads(resp.read().decode("utf-8") or "null")
32
+
33
+ base = f"{api.rstrip('/')}/repos/{repo}/issues"
34
+ try:
35
+ page = 1
36
+ while page <= 10:
37
+ comments = call("GET", f"{base}/{pr}/comments?per_page=100&page={page}") or []
38
+ for c in comments:
39
+ if COMMENT_MARKER in (c.get("body") or ""):
40
+ call("PATCH", f"{base}/comments/{c['id']}", {"body": body})
41
+ return "updated"
42
+ if len(comments) < 100:
43
+ break
44
+ page += 1
45
+ call("POST", f"{base}/{pr}/comments", {"body": body})
46
+ return "created"
47
+ except urllib.error.HTTPError as e:
48
+ if e.code in (403, 404):
49
+ return "forbidden"
50
+ raise
fence/github/sync.py ADDED
@@ -0,0 +1,123 @@
1
+ """Fetch PRs, linked issues and review comments for indexed commits.
2
+
3
+ Resumable: every item is committed as soon as it is fetched, and already-stored
4
+ PRs are skipped, so an interrupted or rate-limited run continues next time.
5
+ Text is stored verbatim and stays untrusted.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from dataclasses import dataclass, field
13
+
14
+ from fence.context import Context
15
+ from fence.github.client import Client, GithubError, RateLimited
16
+ from fence.signals.catalog import extract_text
17
+
18
+
19
+ @dataclass
20
+ class SyncReport:
21
+ prs: int = 0
22
+ issues: int = 0
23
+ review_comments: int = 0
24
+ unreachable: int = 0
25
+ requests: int = 0
26
+ stopped: str | None = None
27
+ notes: list[str] = field(default_factory=list)
28
+
29
+
30
+ def sync(ctx: Context, *, client: Client | None = None, max_prs: int = 500) -> SyncReport:
31
+ rep = SyncReport()
32
+ cfg = ctx.cfg.github
33
+ if cfg.enabled == "off" or not ctx.slug:
34
+ rep.notes.append("GitHub sync skipped (disabled or no GitHub remote)")
35
+ return rep
36
+ client = client or Client(cfg.api_base_url, ctx.github_token, ctx.store,
37
+ min_remaining=cfg.min_remaining, policy=cfg.rate_limit_policy,
38
+ max_requests=cfg.max_requests_per_run)
39
+ owner, name = ctx.slug
40
+ base = f"/repos/{owner}/{name}"
41
+ min_time = int(time.time()) - cfg.max_age_days * 86400 if cfg.max_age_days else 0
42
+ todo = [r["pr_number"] for r in ctx.store.conn.execute(
43
+ "SELECT DISTINCT c.pr_number FROM commits c LEFT JOIN prs p ON p.number = c.pr_number "
44
+ "WHERE c.pr_number IS NOT NULL AND p.number IS NULL AND c.orphaned = 0 "
45
+ "AND c.committer_time >= ? ORDER BY c.committer_time DESC LIMIT ?",
46
+ (min_time, max_prs))]
47
+ try:
48
+ for n in todo if cfg.sync_prs else []:
49
+ _sync_pr(ctx, client, base, n, rep)
50
+ except RateLimited as e:
51
+ rep.stopped = str(e)
52
+ except GithubError as e:
53
+ rep.stopped = str(e)
54
+ rep.requests = client.requests
55
+ return rep
56
+
57
+
58
+ def _sync_pr(ctx: Context, client: Client, base: str, n: int, rep: SyncReport) -> None:
59
+ r = client.get(f"{base}/pulls/{n}")
60
+ if r.status != 200 or not isinstance(r.data, dict):
61
+ with ctx.store.transaction():
62
+ ctx.store.conn.execute(
63
+ "INSERT OR REPLACE INTO prs(number, state, fetched_at) VALUES(?, 'unreachable', ?)",
64
+ (n, int(time.time())))
65
+ rep.unreachable += 1
66
+ return
67
+ d = r.data
68
+ labels = [lb.get("name", "") for lb in d.get("labels") or [] if isinstance(lb, dict)]
69
+ reviewers: set[str] = set()
70
+ comments = []
71
+ if ctx.cfg.github.sync_review_comments:
72
+ for c in client.paginate(f"{base}/pulls/{n}/comments", 200):
73
+ comments.append(c)
74
+ for rv in client.paginate(f"{base}/pulls/{n}/reviews", 100):
75
+ if isinstance(rv, dict) and rv.get("user"):
76
+ reviewers.add(rv["user"].get("login", ""))
77
+ if rv.get("body"):
78
+ comments.append({**rv, "path": None, "line": None})
79
+ with ctx.store.transaction():
80
+ ctx.store.conn.execute(
81
+ "INSERT OR REPLACE INTO prs VALUES(?,?,?,?,?,?,?,?,?,?,?)",
82
+ (n, d.get("title"), d.get("body") or "", json.dumps(labels), d.get("state"),
83
+ d.get("merged_at"), d.get("merge_commit_sha"), d.get("html_url"),
84
+ (d.get("user") or {}).get("login"), json.dumps(sorted(reviewers)),
85
+ int(time.time())))
86
+ for c in comments:
87
+ if not isinstance(c, dict) or "id" not in c:
88
+ continue
89
+ ctx.store.conn.execute(
90
+ "INSERT OR REPLACE INTO review_comments VALUES(?,?,?,?,?,?,?,?)",
91
+ (c["id"], n, c.get("path"), c.get("line"), c.get("body") or "",
92
+ (c.get("user") or {}).get("login"), c.get("html_url"),
93
+ c.get("created_at") or c.get("submitted_at")))
94
+ rep.prs += 1
95
+ rep.review_comments += len(comments)
96
+ if ctx.cfg.github.sync_issues:
97
+ refs = {d for s, d in extract_text(d.get("body") or "")
98
+ if s in ("text.closes_issue", "text.issue_ref") and d.startswith("#")}
99
+ for ref in sorted(refs)[:10]:
100
+ _sync_issue(ctx, client, base, int(ref[1:]), n, rep)
101
+
102
+
103
+ def _sync_issue(ctx: Context, client: Client, base: str, n: int, pr: int,
104
+ rep: SyncReport) -> None:
105
+ if ctx.store.conn.execute("SELECT 1 FROM issues WHERE number=?", (n,)).fetchone():
106
+ return
107
+ r = client.get(f"{base}/issues/{n}")
108
+ with ctx.store.transaction():
109
+ if r.status != 200 or not isinstance(r.data, dict):
110
+ ctx.store.conn.execute(
111
+ "INSERT OR REPLACE INTO issues(number, fetched_at, retrieval_status) "
112
+ "VALUES(?, ?, 'unreachable')", (n, int(time.time())))
113
+ rep.unreachable += 1
114
+ return
115
+ d = r.data
116
+ labels = [lb.get("name", "") for lb in d.get("labels") or [] if isinstance(lb, dict)]
117
+ ctx.store.conn.execute(
118
+ "INSERT OR REPLACE INTO issues VALUES(?,?,?,?,?,?,?,?,'ok')",
119
+ (n, d.get("title"), d.get("body") or "", json.dumps(labels), d.get("state"),
120
+ d.get("closed_at"), d.get("html_url"), int(time.time())))
121
+ ctx.store.conn.execute("INSERT OR IGNORE INTO pr_issue_links VALUES(?,?,?)",
122
+ (pr, n, "references"))
123
+ rep.issues += 1
fence/globs.py ADDED
@@ -0,0 +1,43 @@
1
+ """Gitignore-style globs for include/exclude/ignore_paths: `*`, `?`, `**`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable
7
+ from functools import lru_cache
8
+
9
+
10
+ @lru_cache(maxsize=512)
11
+ def _compile(pattern: str) -> re.Pattern[str]:
12
+ p = pattern.strip().lstrip("/")
13
+ if p.endswith("/"):
14
+ p += "**"
15
+ out = []
16
+ i = 0
17
+ while i < len(p):
18
+ if p.startswith("**/", i):
19
+ out.append("(?:.*/)?")
20
+ i += 3
21
+ elif p.startswith("**", i):
22
+ out.append(".*")
23
+ i += 2
24
+ elif p[i] == "*":
25
+ out.append("[^/]*")
26
+ i += 1
27
+ elif p[i] == "?":
28
+ out.append("[^/]")
29
+ i += 1
30
+ else:
31
+ out.append(re.escape(p[i]))
32
+ i += 1
33
+ # A pattern without a slash matches at any depth, like .gitignore.
34
+ prefix = "" if "/" in pattern.strip().rstrip("/") else "(?:.*/)?"
35
+ return re.compile(prefix + "".join(out) + r"\Z")
36
+
37
+
38
+ def match_any(path: str, patterns: Iterable[str]) -> bool:
39
+ return any(_compile(p).match(path) for p in patterns)
40
+
41
+
42
+ def included(path: str, include: Iterable[str], exclude: Iterable[str]) -> bool:
43
+ return match_any(path, include) and not match_any(path, exclude)
File without changes