diffbutler 0.5.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 (105) hide show
  1. diffbutler/__init__.py +1 -0
  2. diffbutler/__main__.py +6 -0
  3. diffbutler/config/__init__.py +1 -0
  4. diffbutler/config/default.toml +91 -0
  5. diffbutler/config/diagnostics.py +90 -0
  6. diffbutler/config/file.py +30 -0
  7. diffbutler/config/instance.py +55 -0
  8. diffbutler/config/settings.py +297 -0
  9. diffbutler/config/setup.py +92 -0
  10. diffbutler/config/transport.py +65 -0
  11. diffbutler/core/__init__.py +1 -0
  12. diffbutler/core/application.py +103 -0
  13. diffbutler/core/context.py +54 -0
  14. diffbutler/core/dashboard.py +393 -0
  15. diffbutler/core/doctor.py +200 -0
  16. diffbutler/core/lifecycle.py +70 -0
  17. diffbutler/core/managed.py +441 -0
  18. diffbutler/core/native.py +301 -0
  19. diffbutler/core/polling.py +118 -0
  20. diffbutler/core/process.py +64 -0
  21. diffbutler/core/repositories.py +260 -0
  22. diffbutler/core/reset.py +34 -0
  23. diffbutler/core/resources.py +50 -0
  24. diffbutler/core/review.py +331 -0
  25. diffbutler/core/setup.py +232 -0
  26. diffbutler/core/snapshot.py +70 -0
  27. diffbutler/core/sync.py +259 -0
  28. diffbutler/core/worker.py +205 -0
  29. diffbutler/core/workspaces.py +139 -0
  30. diffbutler/domain/__init__.py +1 -0
  31. diffbutler/domain/dashboard.py +63 -0
  32. diffbutler/domain/diff.py +285 -0
  33. diffbutler/domain/forge.py +123 -0
  34. diffbutler/domain/lifecycle.py +51 -0
  35. diffbutler/domain/polling.py +92 -0
  36. diffbutler/domain/process.py +29 -0
  37. diffbutler/domain/redaction.py +61 -0
  38. diffbutler/domain/resources.py +50 -0
  39. diffbutler/domain/review.py +167 -0
  40. diffbutler/domain/sync.py +196 -0
  41. diffbutler/forges/__init__.py +1 -0
  42. diffbutler/forges/base.py +39 -0
  43. diffbutler/forges/diagnostics.py +104 -0
  44. diffbutler/forges/github/__init__.py +1 -0
  45. diffbutler/forges/github/adapter.py +352 -0
  46. diffbutler/forges/github/auth.py +49 -0
  47. diffbutler/forges/github/http.py +326 -0
  48. diffbutler/forges/github/mapping.py +157 -0
  49. diffbutler/forges/github/setup.py +86 -0
  50. diffbutler/forges/registry.py +23 -0
  51. diffbutler/py.typed +0 -0
  52. diffbutler/reviewers/__init__.py +1 -0
  53. diffbutler/reviewers/base.py +101 -0
  54. diffbutler/reviewers/extraction.py +198 -0
  55. diffbutler/reviewers/native.py +238 -0
  56. diffbutler/reviewers/parsing.py +247 -0
  57. diffbutler/reviewers/presets.toml +12 -0
  58. diffbutler/reviewers/process.py +78 -0
  59. diffbutler/reviewers/prompt.py +148 -0
  60. diffbutler/reviewers/redaction.py +30 -0
  61. diffbutler/reviewers/resources.py +162 -0
  62. diffbutler/sources/__init__.py +1 -0
  63. diffbutler/sources/diagnostics.py +100 -0
  64. diffbutler/sources/fetch.py +285 -0
  65. diffbutler/sources/git.py +461 -0
  66. diffbutler/sources/remotes.py +82 -0
  67. diffbutler/store/__init__.py +1 -0
  68. diffbutler/store/base.py +88 -0
  69. diffbutler/store/dashboard.py +201 -0
  70. diffbutler/store/database.py +172 -0
  71. diffbutler/store/lifecycle.py +247 -0
  72. diffbutler/store/migrations/__init__.py +1 -0
  73. diffbutler/store/migrations/env.py +14 -0
  74. diffbutler/store/migrations/versions/0001_initial.py +147 -0
  75. diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
  76. diffbutler/store/migrations/versions/0003_reviews.py +72 -0
  77. diffbutler/store/migrations/versions/0004_claims.py +22 -0
  78. diffbutler/store/migrations/versions/0005_results.py +27 -0
  79. diffbutler/store/migrations/versions/0006_anchors.py +17 -0
  80. diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
  81. diffbutler/store/migrations/versions/0008_processes.py +30 -0
  82. diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
  83. diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
  84. diffbutler/store/migrations/versions/__init__.py +1 -0
  85. diffbutler/store/models.py +290 -0
  86. diffbutler/store/process.py +75 -0
  87. diffbutler/store/queue.py +289 -0
  88. diffbutler/store/review.py +583 -0
  89. diffbutler/ui/__init__.py +1 -0
  90. diffbutler/ui/cli/__init__.py +532 -0
  91. diffbutler/ui/cli/editor.py +33 -0
  92. diffbutler/ui/tui/__init__.py +1 -0
  93. diffbutler/ui/tui/_fake_agent.py +47 -0
  94. diffbutler/ui/tui/_foreground_exec.py +23 -0
  95. diffbutler/ui/tui/app.py +34 -0
  96. diffbutler/ui/tui/browse.py +119 -0
  97. diffbutler/ui/tui/browser.py +42 -0
  98. diffbutler/ui/tui/dashboard.py +926 -0
  99. diffbutler/ui/tui/foreground.py +103 -0
  100. diffbutler/ui/tui/handoff_probe.py +184 -0
  101. diffbutler-0.5.0.dist-info/METADATA +170 -0
  102. diffbutler-0.5.0.dist-info/RECORD +105 -0
  103. diffbutler-0.5.0.dist-info/WHEEL +4 -0
  104. diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
  105. diffbutler-0.5.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,205 @@
1
+ """Worker orchestration for explicit requests, including native CLI execution."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import math
6
+ from collections.abc import Awaitable, Callable
7
+ from contextlib import suppress
8
+ from typing import Protocol
9
+ from uuid import UUID
10
+
11
+ from diffbutler.core.review import AIRun, ReviewService
12
+ from diffbutler.domain.review import ReviewConflict, RunClaim, RunRecord
13
+ from diffbutler.reviewers.base import ReviewContext, ReviewOutput
14
+ from diffbutler.reviewers.extraction import OutputFormat
15
+ from diffbutler.reviewers.parsing import ResponseFormat, Severity
16
+
17
+
18
+ class ReviewExecution(Protocol):
19
+ async def run(
20
+ self,
21
+ context: ReviewContext,
22
+ backend_session_id: str | None,
23
+ checkpoint: Callable[[str], Awaitable[None]],
24
+ ) -> ReviewOutput:
25
+ """Capture output under the configured native permission profile, never a terminal.
26
+
27
+ Implementations must await checkpoint when a backend identity is learned.
28
+ Creating the execution object must not start a process; only run may start
29
+ it. A native adapter must reassert its permission profile and durable ownership,
30
+ including resumes from formerly foreground conversations.
31
+ """
32
+ ...
33
+
34
+ def partial_output(self) -> ReviewOutput:
35
+ """Read captured bytes after stop_and_wait has established exit."""
36
+ ...
37
+
38
+ async def stop_and_wait(self) -> None:
39
+ """Establish whole process-tree exit, including a spawn still in progress.
40
+
41
+ Required on normal return too: protocol completion and EOF do not prove
42
+ exit. Raise if exit cannot be established, leaving durable ownership held.
43
+ This must also settle the run coroutine; no detached I/O may remain.
44
+ """
45
+ ...
46
+
47
+
48
+ async def _settle[T](task: asyncio.Task[T]) -> tuple[T, bool]:
49
+ cancelled = False
50
+ while True:
51
+ try:
52
+ return await asyncio.shield(task), cancelled
53
+ except asyncio.CancelledError:
54
+ if task.done():
55
+ return task.result(), True
56
+ cancelled = True
57
+
58
+
59
+ class ReviewWorker:
60
+ def __init__(
61
+ self,
62
+ reviews: ReviewService,
63
+ prepare: Callable[[RunRecord], Awaitable[ReviewContext]],
64
+ execution_factory: Callable[[RunClaim], ReviewExecution],
65
+ *,
66
+ before_prepare: Callable[[RunClaim], Awaitable[None]] | None = None,
67
+ output_format: OutputFormat | None = None,
68
+ response_format: ResponseFormat = "json",
69
+ poll_interval: float = 0.25,
70
+ min_confidence: float = 0.5,
71
+ findings_threshold: Severity = "minor",
72
+ ) -> None:
73
+ if not math.isfinite(poll_interval) or poll_interval <= 0:
74
+ raise ValueError("poll interval must be positive and finite")
75
+ self.reviews = reviews
76
+ self.before_prepare = before_prepare
77
+ self.prepare = prepare
78
+ self.execution_factory = execution_factory
79
+ self.output_format = output_format
80
+ self.response_format: ResponseFormat = response_format
81
+ self.poll_interval = poll_interval
82
+ self.min_confidence = min_confidence
83
+ self.findings_threshold: Severity = findings_threshold
84
+
85
+ async def execute_requested(
86
+ self,
87
+ run_id: UUID,
88
+ *,
89
+ capacity: int = 2,
90
+ paused: bool = False,
91
+ ) -> AIRun | None:
92
+ """Consume one already requested run, never create or requeue analysis.
93
+
94
+ Factory/prepare are trusted dependencies; native execution relies on the
95
+ installed agent sandbox and process-group lifecycle contract. Cancellation
96
+ releases ownership
97
+ only after stop_and_wait; stop failure deliberately retains the claim.
98
+ """
99
+ claim = await self.reviews.claim_review(run_id, capacity=capacity, paused=paused)
100
+ if claim is None:
101
+ return None
102
+ exited = True # No execution object/process exists during preparation.
103
+ execution: ReviewExecution | None = None
104
+ context: ReviewContext | None = None
105
+ phase = "preparation"
106
+
107
+ try:
108
+ if self.before_prepare is not None:
109
+ await self.before_prepare(claim)
110
+ context = await self.prepare(claim.run)
111
+ if (context.run_id, context.session_id, context.pr.base_sha, context.pr.head_sha) != (
112
+ claim.run.id,
113
+ claim.run.session_id,
114
+ claim.run.base_sha,
115
+ claim.run.head_sha,
116
+ ):
117
+ raise ReviewConflict("Prepared context does not match worker ownership")
118
+ if context.diff_index is None or (
119
+ context.diff_index.base_sha,
120
+ context.diff_index.head_sha,
121
+ ) != (claim.run.base_sha, claim.run.head_sha):
122
+ raise ReviewConflict("Worker requires a pinned original diff index")
123
+ if not await self.reviews.claim_is_current(claim.token):
124
+ return await self.reviews.release_review(claim.token)
125
+ session = await self.reviews.get_session(claim.run.session_id)
126
+
127
+ async def checkpoint(identity: str) -> None:
128
+ await self.reviews.bind_backend_session(claim.token, identity)
129
+
130
+ phase = "agent execution"
131
+ execution = self.execution_factory(claim)
132
+ exited = False
133
+ running = asyncio.create_task(
134
+ execution.run(context, session.backend_session_id, checkpoint)
135
+ )
136
+ try:
137
+ async with asyncio.timeout(context.budget.timeout_s):
138
+ while not running.done():
139
+ await asyncio.wait((running,), timeout=self.poll_interval)
140
+ if not await self.reviews.claim_is_current(claim.token):
141
+ break
142
+ if not running.done():
143
+ output = None
144
+ else:
145
+ output = await running
146
+ finally:
147
+
148
+ async def stop() -> None:
149
+ await execution.stop_and_wait()
150
+ # Exit is established; settle the coroutine as well.
151
+ running.cancel()
152
+ await asyncio.gather(running, return_exceptions=True)
153
+
154
+ _, cancelled = await _settle(asyncio.create_task(stop()))
155
+ exited = True
156
+ if cancelled:
157
+ raise asyncio.CancelledError
158
+ if output is None:
159
+ return await self.reviews.release_review(
160
+ claim.token, reason="Review invalidated", output=execution.partial_output()
161
+ )
162
+ phase = "result processing"
163
+ result = await self.reviews.finalize_review(
164
+ claim.token,
165
+ output,
166
+ diff_index=context.diff_index,
167
+ output_format=self.output_format,
168
+ response_format=self.response_format,
169
+ min_confidence=self.min_confidence,
170
+ findings_threshold=self.findings_threshold,
171
+ )
172
+ if result.status_reason:
173
+ logging.getLogger(__name__).warning(
174
+ "Review ended run=%s status=%s reason=%s",
175
+ result.id,
176
+ result.status,
177
+ result.status_reason,
178
+ )
179
+ return result
180
+ except BaseException as exc:
181
+ if exited:
182
+ if isinstance(exc, asyncio.CancelledError):
183
+ reason = "Review cancelled"
184
+ elif isinstance(exc, TimeoutError) and context is not None:
185
+ seconds = context.budget.timeout_s
186
+ duration = (
187
+ f"{seconds // 60} minutes" if seconds % 60 == 0 else f"{seconds} seconds"
188
+ )
189
+ reason = f"Review timed out after {duration}"
190
+ else:
191
+ # Exception messages may contain PR content, paths or credentials.
192
+ reason = f"Review failed during {phase} ({type(exc).__name__})"
193
+ # An uncertain finalization commit may already have consumed this
194
+ # token. Never retry finalization or overwrite the retained result.
195
+ with suppress(ReviewConflict):
196
+ await _settle(
197
+ asyncio.create_task(
198
+ self.reviews.release_review(
199
+ claim.token,
200
+ reason=reason,
201
+ output=execution.partial_output() if execution else None,
202
+ )
203
+ )
204
+ )
205
+ raise
@@ -0,0 +1,139 @@
1
+ """Reconstructible snapshots; persistent native homes retain their identities."""
2
+
3
+ import json
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+ from uuid import UUID, uuid4
7
+
8
+ from diffbutler.core.repositories import file_lock
9
+ from diffbutler.core.resources import save_json
10
+ from diffbutler.core.snapshot import write_snapshot
11
+ from diffbutler.domain.resources import OwnedDirectory
12
+ from diffbutler.reviewers.resources import OwnedDirectories, settled_io
13
+ from diffbutler.sources.fetch import git_fetch_command
14
+ from diffbutler.sources.git import GitSourceReader
15
+
16
+
17
+ class SnapshotWorkspaces:
18
+ def __init__(self, homes: OwnedDirectories, path: Path | None = None) -> None:
19
+ self.homes = homes
20
+ self.path = path
21
+
22
+ async def directories(self, *, create: bool) -> OwnedDirectories | None:
23
+ record = self.homes.path / ".snapshot-location.json"
24
+ async with file_lock(self.homes.path / ".snapshot-lock"):
25
+ if record.exists():
26
+ data = json.loads(await settled_io(record.read_text))
27
+ path = Path(data["path"])
28
+ identity = UUID(data["root_id"])
29
+ if not path.is_absolute():
30
+ raise ValueError("Invalid snapshot root")
31
+ if self.path is not None and self.path.absolute() != path:
32
+ raise ValueError("Snapshot root cannot change while using this managed home")
33
+ elif self.path is None or not create:
34
+ return None
35
+ else:
36
+ path = self.path.absolute()
37
+ identity = uuid4()
38
+ # Do not adopt preexisting directories, even if they have a marker.
39
+ if path.exists() or path.is_symlink():
40
+ raise ValueError("Snapshot root must be a new private directory")
41
+
42
+ await save_json(record, {"path": str(path), "root_id": str(identity)})
43
+ if not path.exists() and not path.is_symlink():
44
+ if not create:
45
+ return None
46
+ await settled_io(lambda: path.parent.mkdir(parents=True, exist_ok=True))
47
+ return await OwnedDirectories.initialize(path, root_id=identity)
48
+ directories = await OwnedDirectories.reopen(path)
49
+ if directories.root_id != identity:
50
+ raise ValueError("Snapshot root identity mismatch")
51
+ return directories
52
+
53
+ @staticmethod
54
+ def resource(directories: OwnedDirectories, home: OwnedDirectory) -> OwnedDirectory:
55
+ return OwnedDirectory(directories.root_id, home.session_id, home.resource_id)
56
+
57
+ async def session_path(self, home: OwnedDirectory) -> Path:
58
+ directories = await self.directories(create=True)
59
+ if directories is None:
60
+ return self.homes.path / home.name
61
+ resource = self.resource(directories, home)
62
+ path = directories.path / resource.name
63
+ if path.is_symlink():
64
+ raise ValueError("Snapshot workspace was replaced by a symlink")
65
+ if not path.exists():
66
+ await directories.create(resource)
67
+ return path
68
+
69
+ async def cleanup(self, home: OwnedDirectory) -> None:
70
+ directories = await self.directories(create=False)
71
+ if directories is not None:
72
+ await directories.delete(self.resource(directories, home))
73
+
74
+
75
+ async def materialize(
76
+ reader: GitSourceReader,
77
+ destination: Path,
78
+ *,
79
+ progress: Callable[[str], None] | None = None,
80
+ ) -> None:
81
+ """Publish only complete snapshots; an interrupted preparation is reconstructible."""
82
+ temporary = destination.with_name(f".{destination.name}-{uuid4().hex}.tmp")
83
+ await settled_io(lambda: destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True))
84
+ await write_snapshot(reader, temporary, progress=progress)
85
+ await settled_io(lambda: temporary.rename(destination))
86
+
87
+
88
+ async def materialize_checkout(
89
+ reader: GitSourceReader,
90
+ destination: Path,
91
+ *,
92
+ progress: Callable[[str], None] | None = None,
93
+ ) -> None:
94
+ """Create a private head checkout backed by the session-pinned object cache."""
95
+ temporary = destination.with_name(f".{destination.name}-{uuid4().hex}.tmp")
96
+ common = await reader.common_directory()
97
+ if progress is not None:
98
+ progress("Checking out PR sources…")
99
+ await settled_io(lambda: temporary.mkdir(mode=0o700, parents=True))
100
+ await git_fetch_command(
101
+ temporary,
102
+ "init",
103
+ "--template=",
104
+ f"--object-format={'sha256' if len(reader.head_sha) == 64 else 'sha1'}",
105
+ local=True,
106
+ )
107
+
108
+ def configure() -> None:
109
+ gitdir = temporary / ".git"
110
+ # Quote the alternate path using Git's C-style path syntax.
111
+ (gitdir / "objects/info/alternates").write_text(
112
+ json.dumps(str(common / "objects"), ensure_ascii=False) + "\n"
113
+ )
114
+ with (gitdir / "config").open("a") as config:
115
+ config.write(
116
+ "\n[core]\n\thooksPath = /dev/null\n\tfsmonitor = false\n\tautocrlf = false\n"
117
+ )
118
+
119
+ await settled_io(configure)
120
+ await git_fetch_command(
121
+ temporary, "update-ref", "refs/heads/diffbutler-base", reader.base_sha, local=True
122
+ )
123
+ await git_fetch_command(
124
+ temporary,
125
+ "update-ref",
126
+ "refs/heads/diffbutler-merge-base",
127
+ reader.comparison_base_sha,
128
+ local=True,
129
+ )
130
+ await git_fetch_command(
131
+ temporary,
132
+ "checkout",
133
+ "--no-recurse-submodules",
134
+ "-b",
135
+ "diffbutler-pr",
136
+ reader.head_sha,
137
+ local=True,
138
+ )
139
+ await settled_io(lambda: temporary.rename(destination))
@@ -0,0 +1 @@
1
+ """Pure domain entities and workflow rules."""
@@ -0,0 +1,63 @@
1
+ """Local workflow and historical cycle values, without rendering or I/O."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from uuid import UUID
6
+
7
+ from diffbutler.domain.forge import Account, PullRequest
8
+ from diffbutler.domain.review import RunRecord, SessionRecord
9
+ from diffbutler.domain.sync import CommentEvent, ReviewEvent
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class CycleRecord:
14
+ task_id: UUID
15
+ account: Account
16
+ pull_request: PullRequest
17
+ cycle: int
18
+ state: str
19
+ completed_at: datetime | None
20
+ completion_reason: str | None
21
+ completed_head_sha: str | None
22
+ cleanup_status: str
23
+ cleanup_error: str | None
24
+ opened_at: datetime | None
25
+ runs: tuple[RunRecord, ...]
26
+ sessions: tuple[SessionRecord, ...]
27
+ receipts: tuple[ReviewEvent | CommentEvent, ...]
28
+
29
+
30
+ COLUMN_IDS = (
31
+ "state",
32
+ "attention",
33
+ "repo",
34
+ "number",
35
+ "title",
36
+ "author",
37
+ "ai_status",
38
+ "findings",
39
+ "max_severity",
40
+ "age",
41
+ "updated",
42
+ "branch",
43
+ "draft",
44
+ "reviewers",
45
+ "unverified",
46
+ "url",
47
+ "additions",
48
+ "deletions",
49
+ "files",
50
+ "labels",
51
+ "ci",
52
+ )
53
+
54
+ STATE_LABELS = {
55
+ "PENDING_AI_REVIEW": "pending",
56
+ "QUEUED": "queued",
57
+ "AI_RUNNING": "running",
58
+ "PENDING_HUMAN_REVIEW": "approve",
59
+ "FINDINGS_TRIAGE": "check",
60
+ "PENDING_AUTHOR_CHANGES": "author",
61
+ "DONE": "done",
62
+ "DONE_PLUS": "done+",
63
+ }
@@ -0,0 +1,285 @@
1
+ """Forge-neutral anchors and validation outcomes; no source text is retained."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, replace
5
+ from typing import Literal
6
+
7
+ from diffbutler.domain.review import Side, SourceFile
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class DiffAnchor:
12
+ path: str
13
+ line: int
14
+ side: Side
15
+ start_line: int | None = None
16
+ old_path: str | None = None
17
+ base_sha: str = ""
18
+ head_sha: str = ""
19
+
20
+ def __post_init__(self) -> None:
21
+ SourceFile(self.path, self.side)
22
+ if self.old_path is not None:
23
+ SourceFile(self.old_path, Side.BASE)
24
+ if self.line < 1 or (self.start_line is not None and not 1 <= self.start_line <= self.line):
25
+ raise ValueError("anchor lines must be positive and ordered")
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class AnchorValidation:
30
+ original: DiffAnchor
31
+ anchor: DiffAnchor | None
32
+ status: Literal["exact", "snapped", "demoted"]
33
+
34
+
35
+ _HUNK = re.compile(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?")
36
+ _BINARY = re.compile(
37
+ r'Binary files ("(?:[^"\\]|\\.)*"|a/.+?|/dev/null)'
38
+ r' and ("(?:[^"\\]|\\.)*"|b/.+|/dev/null) differ'
39
+ )
40
+
41
+
42
+ class InvalidDiff(ValueError):
43
+ def __init__(self) -> None:
44
+ super().__init__("Malformed or unsupported unified diff")
45
+
46
+
47
+ def _path(value: str, prefix: str) -> str | None:
48
+ if value == "/dev/null":
49
+ return None
50
+ # Git quotes tabs, newlines, non-ASCII bytes and backslashes using C escapes.
51
+ if value.startswith('"'):
52
+ if not value.endswith('"'):
53
+ raise InvalidDiff()
54
+ raw = bytearray()
55
+ text = value[1:-1]
56
+ offset = 0
57
+ escapes = {"a": 7, "b": 8, "t": 9, "n": 10, "v": 11, "f": 12, "r": 13, '"': 34, "\\": 92}
58
+ while offset < len(text):
59
+ char = text[offset]
60
+ offset += 1
61
+ if char != "\\":
62
+ raw.extend(char.encode("utf-8"))
63
+ continue
64
+ if offset == len(text):
65
+ raise InvalidDiff()
66
+ char = text[offset]
67
+ if char in escapes:
68
+ raw.append(escapes[char])
69
+ offset += 1
70
+ elif re.fullmatch(r"[0-3][0-7]{2}", text[offset : offset + 3]):
71
+ raw.append(int(text[offset : offset + 3], 8))
72
+ offset += 3
73
+ else:
74
+ raise InvalidDiff()
75
+ try:
76
+ value = raw.decode("utf-8")
77
+ except UnicodeDecodeError:
78
+ raise InvalidDiff() from None
79
+ else:
80
+ value = value.removesuffix("\t")
81
+ if not value.startswith(prefix):
82
+ raise InvalidDiff()
83
+ path = value[len(prefix) :]
84
+ try:
85
+ SourceFile(path, Side.HEAD)
86
+ except ValueError:
87
+ raise InvalidDiff() from None
88
+ return path
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class _Hunk:
93
+ path: str
94
+ old_path: str | None
95
+ base_start: int
96
+ base_count: int
97
+ head_start: int
98
+ head_count: int
99
+
100
+ def bounds(self, side: Side) -> tuple[int, int]:
101
+ start, count = (
102
+ (self.base_start, self.base_count)
103
+ if side == Side.BASE
104
+ else (self.head_start, self.head_count)
105
+ )
106
+ return start, start + count - 1
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class DiffIndex:
111
+ """Index the original diff before redaction. No source text is stored.
112
+
113
+ Paths use the head name, or the base name for a deletion. Context lines are
114
+ commentable on both sides. Ranges must fit a single hunk on one side. Snapping
115
+ clips a range overlapping exactly one hunk, within the endpoint window; it
116
+ never guesses a hunk for a location wholly outside the diff or crosses sides.
117
+ """
118
+
119
+ hunks: tuple[_Hunk, ...]
120
+ base_sha: str = ""
121
+ head_sha: str = ""
122
+
123
+ @classmethod
124
+ def parse(cls, diff: str, *, base_sha: str = "", head_sha: str = "") -> DiffIndex:
125
+ if "\x00" in diff:
126
+ raise InvalidDiff()
127
+ lines = diff.split("\n")
128
+ if lines[-1] == "":
129
+ lines.pop()
130
+ hunks: list[_Hunk] = []
131
+ old: str | None = None
132
+ new: str | None = None
133
+ in_file = headers = has_hunk = False
134
+ seen: set[str] = set()
135
+ offset = 0
136
+ while offset < len(lines):
137
+ line = lines[offset]
138
+ offset += 1
139
+ if line.startswith("diff --git "):
140
+ if headers and not has_hunk:
141
+ raise InvalidDiff()
142
+ in_file, headers, has_hunk = True, False, False
143
+ old = new = None
144
+ elif (
145
+ line.startswith(
146
+ (
147
+ "index ",
148
+ "old mode ",
149
+ "new mode ",
150
+ "new file mode ",
151
+ "deleted file mode ",
152
+ "similarity index ",
153
+ "rename from ",
154
+ "rename to ",
155
+ )
156
+ )
157
+ and in_file
158
+ and not headers
159
+ ):
160
+ continue
161
+ elif (binary := _BINARY.fullmatch(line)) is not None and in_file and not headers:
162
+ old = _path(binary[1], "a/")
163
+ new = _path(binary[2], "b/")
164
+ path = new or old
165
+ if path is None or path in seen:
166
+ raise InvalidDiff()
167
+ seen.add(path)
168
+ # A summary has no commentable lines and ends this file.
169
+ in_file = False
170
+ elif line.startswith("--- ") and in_file and not headers:
171
+ if offset >= len(lines) or not lines[offset].startswith("+++ "):
172
+ raise InvalidDiff()
173
+ old = _path(line[4:], "a/")
174
+ new = _path(lines[offset][4:], "b/")
175
+ offset += 1
176
+ if old is None and new is None:
177
+ raise InvalidDiff()
178
+ path = new or old
179
+ assert path is not None
180
+ if path in seen:
181
+ raise InvalidDiff()
182
+ seen.add(path)
183
+ headers = True
184
+ elif (match := _HUNK.fullmatch(line)) is not None and headers:
185
+ bs, bc, hs, hc = (
186
+ int(match[1]),
187
+ int(match[2] or 1),
188
+ int(match[3]),
189
+ int(match[4] or 1),
190
+ )
191
+ if (bc and bs < 1) or (hc and hs < 1) or not bc + hc:
192
+ raise InvalidDiff()
193
+ if (old is None and bc) or (new is None and hc):
194
+ raise InvalidDiff()
195
+ path = new or old
196
+ assert path is not None
197
+ hunk = _Hunk(path, old, bs, bc, hs, hc)
198
+ if hunks and hunks[-1].path == path:
199
+ prev = hunks[-1]
200
+ if (
201
+ bs < prev.base_start + prev.base_count
202
+ or hs < prev.head_start + prev.head_count
203
+ ):
204
+ raise InvalidDiff()
205
+ b = h = 0
206
+ marker_allowed = False
207
+ while b < bc or h < hc:
208
+ if offset >= len(lines):
209
+ raise InvalidDiff()
210
+ body = lines[offset]
211
+ offset += 1
212
+ if body == "\" and marker_allowed:
213
+ marker_allowed = False
214
+ continue
215
+ if not body or body[0] not in " +-":
216
+ raise InvalidDiff()
217
+ b += body[0] in " -"
218
+ h += body[0] in " +"
219
+ if b > bc or h > hc:
220
+ raise InvalidDiff()
221
+ marker_allowed = True
222
+ if offset < len(lines) and lines[offset] == "\":
223
+ offset += 1
224
+ hunks.append(hunk)
225
+ has_hunk = True
226
+ else:
227
+ raise InvalidDiff()
228
+ if headers and not has_hunk:
229
+ raise InvalidDiff()
230
+ return cls(tuple(hunks), base_sha, head_sha)
231
+
232
+ def _matching(self, anchor: DiffAnchor) -> tuple[_Hunk, ...]:
233
+ if (anchor.base_sha and anchor.base_sha != self.base_sha) or (
234
+ anchor.head_sha and anchor.head_sha != self.head_sha
235
+ ):
236
+ return ()
237
+ return tuple(
238
+ hunk
239
+ for hunk in self.hunks
240
+ if hunk.path == anchor.path
241
+ and (anchor.old_path is None or anchor.old_path == hunk.old_path)
242
+ )
243
+
244
+ def is_commentable(self, anchor: DiffAnchor) -> bool:
245
+ start = anchor.start_line or anchor.line
246
+ return any(
247
+ low <= start <= anchor.line <= high
248
+ for low, high in (hunk.bounds(anchor.side) for hunk in self._matching(anchor))
249
+ )
250
+
251
+ def snap(self, anchor: DiffAnchor, *, window: int = 3) -> DiffAnchor | None:
252
+ if window < 0:
253
+ raise ValueError("snapping window must be nonnegative")
254
+ start = anchor.start_line or anchor.line
255
+ matches = [
256
+ hunk
257
+ for hunk in self._matching(anchor)
258
+ if (bounds := hunk.bounds(anchor.side))[0] <= bounds[1]
259
+ and start <= bounds[1]
260
+ and anchor.line >= bounds[0]
261
+ ]
262
+ if len(matches) != 1:
263
+ return None
264
+ hunk = matches[0]
265
+ low, high = hunk.bounds(anchor.side)
266
+ first, last = max(start, low), min(anchor.line, high)
267
+ if first - start > window or anchor.line - last > window:
268
+ return None
269
+ return replace(
270
+ anchor,
271
+ line=last,
272
+ start_line=first if anchor.start_line else None,
273
+ old_path=hunk.old_path,
274
+ base_sha=self.base_sha,
275
+ head_sha=self.head_sha,
276
+ )
277
+
278
+ def validate(self, anchor: DiffAnchor, *, window: int = 3) -> AnchorValidation:
279
+ snapped = self.snap(anchor, window=window)
280
+ status = (
281
+ "demoted"
282
+ if snapped is None
283
+ else ("exact" if self.is_commentable(anchor) else "snapped")
284
+ )
285
+ return AnchorValidation(anchor, snapped, status)