Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/skills/label-issue/references/result-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ uv run --no-project python tasks/scripts/label_issue.py expand \

Use evidence IDs from the snapshot; repeat `--evidence-id` as needed. Deferred text uses `text:SOURCE_ID:DIGEST:START:END` with character offsets. PR source slices use `source:PATH:START_LINE:END_LINE`, inclusive, at the prepared head. Inspect returned content and coverage before citing it.

For missing or incomplete per-file patches, the helper tries the raw PR diff and validates file identity, changed-line counts and complete hunks between head/base freshness checks. Recovered content records `patch_source` with endpoint, revisions, source digest and byte count. Failed recovery records `patch_recovery_error` and leaves coverage partial, blocking label writes. Transport size and time bounds apply; source recovery does not resolve classifier context limits.

Write `result.json` using the contract below, then:

```shell
Expand Down
114 changes: 102 additions & 12 deletions tasks/scripts/label_issue_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from __future__ import annotations

import ast
import base64
import copy
import hashlib
Expand Down Expand Up @@ -724,25 +725,112 @@ def _patch_coverage(entry):
):
return "partial"
plus = minus = 0
for line in patch.splitlines():
if line.startswith("+"):
expected = None
old = new = 0
lines = patch.split("\n")
if lines[-1] == "":
lines.pop()
for line in lines:
if line.startswith("@@ "):
if expected is not None and (old, new) != expected:
return "partial"
match = re.fullmatch(r"@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@[^\n]*", line)
if not match:
return "partial"
expected = (int(match[1] or 1), int(match[2] or 1))
old = new = 0
elif expected is None:
return "partial"
elif line.startswith("+"):
plus += 1
new += 1
elif line.startswith("-"):
minus += 1
if plus != entry.get("additions") or minus != entry.get("deletions"):
return "partial"
# Changed-line counts detect omissions even when the provider sets no flag.
for old_count, new_count, body in re.findall(
r"@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@[^\n]*\n(.*?)(?=\n@@|\Z)", patch, re.S
):
lines = body.splitlines()
old = sum(line.startswith(("-", " ")) for line in lines)
new = sum(line.startswith(("+", " ")) for line in lines)
if old != int(old_count or 1) or new != int(new_count or 1):
old += 1
elif line.startswith(" "):
old += 1
new += 1
elif line != "\\ No newline at end of file":
return "partial"
if (
expected is None
or (old, new) != expected
or plus != entry.get("additions")
or minus != entry.get("deletions")
):
return "partial"
return "complete"


def _diff_path(value, prefix):
if value == "/dev/null":
return None
if value.startswith('"'):
# Git quotes UTF-8 bytes using C escapes, including octal sequences.
value = ast.literal_eval("b" + value).decode("utf-8")
if not value.startswith(prefix):
raise ValueError("Unexpected raw diff path prefix")
return value[len(prefix) :]


def _raw_patches(text):
patches = {}
for section in re.split(r"^diff --git ", text, flags=re.M)[1:]:
header, separator, body = section.partition("\n@@ ")
if not separator:
continue # Binary and metadata-only changes have no text hunks.
old = re.findall(r"^--- (.+)$", header, re.M)
new = re.findall(r"^\+\+\+ (.+)$", header, re.M)
if len(old) != 1 or len(new) != 1:
raise ValueError("Missing or ambiguous raw diff paths")
identity = (_diff_path(old[0], "a/"), _diff_path(new[0], "b/"))
if identity in patches:
raise ValueError("Duplicate raw diff file identity")
patches[identity] = "@@ " + body
return patches


def _recover_patches(snapshot, entries, api):
missing = [entry for entry in entries if _patch_coverage(entry) != "complete"]
if not missing:
return
endpoint = f"repos/{snapshot['repository']}/pulls/{snapshot['number']}"
try:
text = api(endpoint, diff=True)
if not isinstance(text, str):
raise ValueError("Raw PR diff is not text")
patches = _raw_patches(text)
except (subprocess.CalledProcessError, ValueError, SyntaxError) as exc:
for entry in missing:
entry["patch_recovery_error"] = str(exc)
return
provenance = {
"endpoint": endpoint,
"media_type": "application/vnd.github.diff",
"source_digest": hashlib.sha256(text.encode()).hexdigest(),
"source_bytes": len(text.encode()),
"head": snapshot["head"],
"base": snapshot["item_material"]["base"]["sha"],
}
for entry in missing:
path, status = entry["filename"], entry.get("status")
old = None if status == "added" else entry.get("previous_filename", path)
new = None if status == "removed" else path
patch = patches.get((old, new))
candidate = {**entry, "patch": patch}
candidate.pop("truncated", None)
candidate.pop("patch_truncated", None)
if _patch_coverage(candidate) == "complete":
entry.update(candidate)
entry.pop("truncated", None)
entry.pop("patch_truncated", None)
entry["patch_source"] = provenance
else:
entry["patch_recovery_error"] = (
"No complete raw diff patch for file identity and counts"
)


def _source_slice(snapshot, artifact_id, api):
match = re.fullmatch(r"source:(.+):(\d+):(\d+)", artifact_id)
if not match or snapshot["kind"] != "pull_request" or not snapshot.get("head"):
Expand Down Expand Up @@ -819,6 +907,8 @@ def _refresh_collection(snapshot, artifact_id, api):
_verify_head(snapshot, api)
content = api(endpoint, pages=True)
if artifact_id == "files":
if isinstance(content, list):
_recover_patches(snapshot, content, api)
_verify_head(snapshot, api)
if not isinstance(content, list):
raise ValueError("Collection must be a paginated list")
Expand Down
142 changes: 142 additions & 0 deletions tasks/scripts/label_issue_evidence_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
preflight,
prepare_snapshot,
)
from label_issue_github import GitHubError

REPO = "Owner/Repo"
ROOT = f"repos/{REPO}"
Expand Down Expand Up @@ -66,6 +67,7 @@ def __init__(self, *, pull=False):
self.closing = []
self.lifecycle_events = []
self.fail = None
self.diff = ""

def __call__(self, path, **kwargs):
self.calls.append((path, kwargs))
Expand All @@ -85,6 +87,10 @@ def __call__(self, path, **kwargs):
if path == f"{ROOT}/issues/3":
return copy.deepcopy(self.item)
if path == f"{ROOT}/pulls/3":
if kwargs.get("diff"):
if isinstance(self.diff, Exception):
raise self.diff
return self.diff
return copy.deepcopy(self.pull)
if path == f"{ROOT}/issues/3/labels?per_page=100":
return [{"name": name} for name in self.labels]
Expand Down Expand Up @@ -452,6 +458,141 @@ def test_omitted_or_incomplete_patch_never_claims_complete_diff(self):
)
self.assertEqual(snapshot["changed_paths"], ["src/example.py"])

def raw_patch(self, old="a/src/example.py", new="b/src/example.py"):
return f"diff --git {old} {new}\nindex abc..def 100644\n--- {old}\n+++ {new}\n@@ -1 +1 @@\n-old\n+new\n"

def missing_patch(self, **overrides):
self.api = API(pull=True)
self.api.collections["files"] = [
{
"filename": "src/example.py",
"status": "modified",
"additions": 1,
"deletions": 1,
**overrides,
}
]
self.api.diff = self.raw_patch()

def test_raw_diff_recovers_omitted_and_incomplete_patches_with_provenance(self):
for partial in ({}, {"patch": "@@ -1 +1 @@\n-old", "patch_truncated": True}):
with self.subTest(partial=partial):
self.missing_patch(**partial)
snapshot = self.prepare()
entry = snapshot["evidence_index"]["patch:src/example.py"]
self.assertEqual(entry["coverage"], "complete")
source = entry["content"]["patch_source"]
self.assertEqual(source["head"], "head-one")
self.assertEqual(source["base"], "base-one")
self.assertEqual(source["source_bytes"], len(self.api.diff.encode()))
self.assertEqual(
source["source_digest"],
hashlib.sha256(self.api.diff.encode()).hexdigest(),
)
digest = entry["source_digest"]
expand_evidence(snapshot, ["files"], api=self.api)
self.assertEqual(
snapshot["evidence_index"]["patch:src/example.py"]["source_digest"],
digest,
)
self.assertEqual(
snapshot["evidence_index"]["files"]["coverage"], "complete"
)

def test_raw_diff_matches_add_remove_rename_and_quoted_utf8_paths(self):
for overrides, old, new in (
(
{"status": "renamed", "previous_filename": "old.py"},
"a/old.py",
"b/src/example.py",
),
({"status": "added", "deletions": 0}, "/dev/null", "b/src/example.py"),
({"status": "removed", "additions": 0}, "a/src/example.py", "/dev/null"),
(
{"filename": "é name.py"},
'"a/\\303\\251 name.py"',
'"b/\\303\\251 name.py"',
),
):
with self.subTest(overrides=overrides):
self.missing_patch(**overrides)
self.api.diff = self.raw_patch(old, new)
if overrides.get("status") == "added":
self.api.diff = self.api.diff.replace(
"@@ -1 +1 @@\n-old", "@@ -0,0 +1 @@"
)
elif overrides.get("status") == "removed":
self.api.diff = self.api.diff.replace(
"@@ -1 +1 @@", "@@ -1 +0,0 @@"
).replace("+new\n", "")
snapshot = self.prepare()
self.assertEqual(
snapshot["evidence_index"]["files"]["coverage"], "complete"
)

def test_failed_or_mismatched_recovery_stays_partial(self):
self.missing_patch()
valid = self.api.diff
for raw in (
"",
valid[:-6],
valid.replace("+new", "+new\n+extra"),
valid.replace("@@ -1 +1 @@", "@@ broken @@"),
valid.replace("@@ -1 +1 @@", "@@ -1,2 +1,2 @@"),
valid.replace("src/example.py", "other.py"),
valid + valid,
valid.replace("+new", "?bad\n+new"),
GitHubError("unavailable", status=503),
valid.replace("--- a/src/example.py", '--- "unterminated'),
"diff --git a/src/example.py b/src/example.py\nBinary files differ\n",
UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid byte"),
):
with self.subTest(raw=raw):
self.api.diff = raw
snapshot = self.prepare()
entry = snapshot["evidence_index"]["patch:src/example.py"]
self.assertEqual(entry["coverage"], "partial")
self.assertNotIn("patch_source", entry["content"])
self.assertIn("patch_recovery_error", entry["content"])
self.assertEqual(
snapshot["evidence_index"]["files"]["coverage"], "partial"
)

def test_raw_diff_accepts_complete_multiple_hunks_and_no_newline_marker(self):
self.missing_patch(additions=2, deletions=2)
self.api.diff += "@@ -9 +9 @@\n-old\r\n\\ No newline at end of file\n+new\r\n\\ No newline at end of file\n"
snapshot = self.prepare()
self.assertEqual(snapshot["evidence_index"]["files"]["coverage"], "complete")

def test_raw_diff_is_fetched_once_for_multiple_missing_files(self):
self.missing_patch()
self.api.collections["files"].append(
{**self.api.collections["files"][0], "filename": "other.py"}
)
self.api.pull["changed_files"] = 2
self.api.diff += self.raw_patch("a/other.py", "b/other.py")
snapshot = self.prepare()
self.assertEqual(snapshot["evidence_index"]["files"]["coverage"], "complete")
self.assertEqual(
sum(bool(kwargs.get("diff")) for _, kwargs in self.api.calls), 1
)

def test_revision_race_during_raw_diff_fetch_rejects_snapshot(self):
for revision in ("head", "base"):
with self.subTest(revision=revision):
self.missing_patch()
original = self.api

def moving(path, original=original, revision=revision, **kwargs):
value = original(path, **kwargs)
if kwargs.get("diff"):
original.pull[revision]["sha"] = "moved"
return value

self.api = moving
with self.assertRaisesRegex(ValueError, "head/base moved"):
self.prepare()

def test_complete_patch_and_pinned_focused_source_slice(self):
self.api = API(pull=True)
self.api.collections["files"] = [
Expand All @@ -465,6 +606,7 @@ def test_complete_patch_and_pinned_focused_source_slice(self):
}
]
snapshot = self.prepare()
self.assertFalse(any(kwargs.get("diff") for _, kwargs in self.api.calls))
expand_evidence(snapshot, ["files"], api=self.api)
self.assertEqual(snapshot["evidence_index"]["files"]["coverage"], "complete")
expand_evidence(snapshot, ["source:src/example.py:2:3"], api=self.api)
Expand Down
Loading
Loading