-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Feat/persist inline comments #2358
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
avidspartan1
wants to merge
9
commits into
The-PR-Agent:main
Choose a base branch
from
avidspartan1:feat/persist-inline-comments
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 6 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
5052492
fix: respect disable_auto_feedback for automatic triggers
avidspartan1 3c21998
fix: preserve user PR description section
avidspartan1 98aab08
feat: support Claude Sonnet extended thinking models
avidspartan1 9e1a054
fix: log GitHub repo settings load failures
avidspartan1 c95ad5a
feat: persist inline suggestion comments
avidspartan1 9089f58
fix: validate claude thinking model override
avidspartan1 548207d
docs: add repo context files design
avidspartan1 568eb42
feat: add configurable repo context files
avidspartan1 0dc5adf
fix: harden persistent inline comment updates
avidspartan1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,210 @@ | ||
| """ | ||
| Stable-marker deduplication for inline PR comments. | ||
|
|
||
| When PR-Agent re-runs /improve or /add_docs on the same PR, each run would | ||
| otherwise post fresh inline comments for suggestions that were already posted. | ||
| This module generates a hidden, content-derived marker that providers embed | ||
| in inline comment bodies so that subsequent runs can recognize and update | ||
| (or skip) the prior comment instead of creating a duplicate. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
| import re | ||
| import textwrap | ||
| from typing import Any, Optional | ||
|
|
||
| MARKER_PREFIX = "<!-- pr-agent-inline-id:" | ||
| MARKER_SUFFIX = " -->" | ||
|
|
||
| # Constants used by the resolve-outdated-inline-comments feature. | ||
| # RESOLVED_BODY_MARKER is appended (with RESOLVED_NOTE) to the body of an | ||
| # inline comment whose suggestion was not re-emitted on the current run. | ||
| # It also serves as an idempotency signal: if a user manually unresolves a | ||
| # thread we previously auto-resolved, the marker remains in the body and | ||
| # tells us not to re-resolve on subsequent runs. | ||
| RESOLVED_NOTE = "Resolved automatically: this suggestion was not re-emitted on the latest run." | ||
| RESOLVED_BODY_MARKER = "<!-- pr-agent-inline-resolved -->" | ||
|
|
||
| PERSISTENT_MODE_OFF = "off" | ||
| PERSISTENT_MODE_UPDATE = "update" | ||
| PERSISTENT_MODE_SKIP = "skip" | ||
| VALID_PERSISTENT_MODES = {PERSISTENT_MODE_OFF, PERSISTENT_MODE_UPDATE, PERSISTENT_MODE_SKIP} | ||
|
|
||
| _HASH_LEN = 12 | ||
| _CONTENT_PREFIX_LEN = 128 | ||
| _MARKER_RE = re.compile( | ||
| re.escape(MARKER_PREFIX) + r"([0-9a-f]{" + str(_HASH_LEN) + r"})" + re.escape(MARKER_SUFFIX) | ||
| ) | ||
| _WHITESPACE_RE = re.compile(r"\s+") | ||
|
|
||
| _SEP = "\x00" | ||
| _HASH_VERSION_STRUCTURED = "v2s" | ||
| _HASH_VERSION_PROSE = "v2p" | ||
|
|
||
|
|
||
| def _pick_content(suggestion: dict) -> Optional[str]: | ||
| for key in ("suggestion_content", "suggestion_summary", "content"): | ||
| val = suggestion.get(key) | ||
| if val: | ||
| return str(val) | ||
| return None | ||
|
|
||
|
|
||
| def _normalize(text: str) -> str: | ||
| return _WHITESPACE_RE.sub(" ", text).strip() | ||
|
|
||
|
|
||
| def normalize_code(text: Optional[str]) -> str: | ||
| """Normalize a proposed-edit code snippet for stable hashing. | ||
|
|
||
| Expands tabs, strips trailing whitespace per line, drops leading and | ||
| trailing fully-blank lines, and removes the longest common leading | ||
| whitespace across remaining lines (textwrap.dedent). | ||
| """ | ||
| if not text: | ||
| return "" | ||
| expanded = text.expandtabs() | ||
| lines = [line.rstrip() for line in expanded.split("\n")] | ||
| while lines and not lines[0]: | ||
| lines.pop(0) | ||
| while lines and not lines[-1]: | ||
| lines.pop() | ||
| if not lines: | ||
| return "" | ||
| return textwrap.dedent("\n".join(lines)) | ||
|
|
||
|
|
||
| # Dedup identity is structured-first, prose-fallback: | ||
| # - If a suggestion has `improved_code`, the hash covers | ||
| # (version_tag + file + normalized improved_code). Prose wording never | ||
| # affects the key, and label is intentionally excluded — the edit | ||
| # itself is the identity. | ||
| # - Otherwise we fall back to (version_tag + file + label + prose prefix). | ||
| # | ||
| # This is a strict (a) design: prose is NEVER consulted when a structured | ||
| # edit exists. Two suggestions at the same spot with the same prose but | ||
| # different edits intentionally remain separate comments — we'd rather | ||
| # under-merge than over-merge genuinely distinct fixes. | ||
| # | ||
| # Line-range is deliberately NOT in the key so dedup stays stable across | ||
| # upstream pushes that drift the target line (a property the user-facing | ||
| # docs explicitly promise). | ||
| # | ||
| # The version tag (v2s / v2p) lives INSIDE the hashed signature, making | ||
| # the two namespaces preimage-distinct and preventing accidental cross- | ||
| # namespace collisions. The marker grammar is unchanged, so pre-existing | ||
| # v1 markers on live PRs self-heal via the outdated-pass auto-resolve | ||
| # (resolve_outdated_inline_comments) on the first re-run after deployment. | ||
| # | ||
| # A fuzzy near-miss signal (shingle / Jaccard) was considered and deferred; | ||
| # see docs/docs/tools/improve.md and the Serena memory | ||
| # `future_fuzzy_inline_dedup`. | ||
| def generate_marker(suggestion: dict) -> Optional[str]: | ||
| """Return a stable marker for this suggestion, or None if required fields are missing.""" | ||
| file = suggestion.get("relevant_file") | ||
| if not file: | ||
| return None | ||
| file = str(file).strip() | ||
| if not file: | ||
| return None | ||
|
|
||
| improved_code = suggestion.get("improved_code") | ||
| if isinstance(improved_code, str) and improved_code.strip(): | ||
| sig = _SEP.join([_HASH_VERSION_STRUCTURED, file, normalize_code(improved_code)]) | ||
| else: | ||
| label = suggestion.get("label") | ||
| content = _pick_content(suggestion) | ||
| if not label or not content: | ||
| return None | ||
| sig = _SEP.join([ | ||
| _HASH_VERSION_PROSE, | ||
| file, | ||
| str(label).strip(), | ||
| _normalize(content)[:_CONTENT_PREFIX_LEN], | ||
| ]) | ||
|
|
||
| digest = hashlib.sha256(sig.encode("utf-8")).hexdigest()[:_HASH_LEN] | ||
| return f"{MARKER_PREFIX}{digest}{MARKER_SUFFIX}" | ||
|
|
||
|
|
||
| def extract_marker(body: str) -> Optional[str]: | ||
| """Return the last marker hash found in `body`, or None.""" | ||
| if not body: | ||
| return None | ||
| matches = _MARKER_RE.findall(body) | ||
| if not matches: | ||
| return None | ||
| return matches[-1] | ||
|
|
||
|
|
||
| def append_marker(body: str, marker: str) -> str: | ||
| """Append `marker` to `body` if not already present; idempotent.""" | ||
| if not marker: | ||
| return body | ||
| if marker in body: | ||
| return body | ||
| sep = "" if body.endswith("\n") else "\n\n" | ||
| return f"{body}{sep}{marker}" | ||
|
|
||
|
|
||
| def build_marker_index(comments: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: | ||
| """Index comments by marker hash, preserving all collisions under the same hash.""" | ||
| index: dict[str, list[dict[str, Any]]] = {} | ||
| for c in comments or []: | ||
| body = c.get("body") or "" | ||
| h = extract_marker(body) | ||
| if h: | ||
| index.setdefault(h, []).append(c) | ||
| return index | ||
|
|
||
|
|
||
| def find_comment_by_location( | ||
| candidates: list[dict[str, Any]], | ||
| relevant_file: str, | ||
| relevant_lines_start: int, | ||
| relevant_lines_end: int, | ||
| ) -> Optional[dict[str, Any]]: | ||
| """Return the newest candidate whose stored inline coordinates match this suggestion.""" | ||
| if not candidates: | ||
| return None | ||
| expected_path = (relevant_file or "").strip() | ||
| expected_line = relevant_lines_end if relevant_lines_end > relevant_lines_start else relevant_lines_start | ||
| expected_start = relevant_lines_start if relevant_lines_end > relevant_lines_start else None | ||
|
|
||
| for candidate in reversed(candidates): | ||
| candidate_path = str(candidate.get("path") or "").strip() | ||
| candidate_line = candidate.get("line") | ||
| candidate_start = candidate.get("start_line") | ||
| if candidate_path != expected_path: | ||
| continue | ||
| if candidate_line != expected_line: | ||
| continue | ||
| if candidate_start != expected_start: | ||
| continue | ||
| return candidate | ||
| return None | ||
|
|
||
|
|
||
| def format_resolved_body(original_body: str) -> str: | ||
| """Append the auto-resolved note and idempotency marker to ``original_body``. | ||
|
|
||
| Shared by every provider's outdated pass so the on-screen format stays | ||
| identical and the body marker check (RESOLVED_BODY_MARKER in body) keeps | ||
| working across providers. | ||
| """ | ||
| return ( | ||
| (original_body or "").rstrip() | ||
| + f"\n\n---\n_{RESOLVED_NOTE}_\n{RESOLVED_BODY_MARKER}" | ||
| ) | ||
|
|
||
|
|
||
| def normalize_persistent_mode(raw: Any) -> str: | ||
| """Coerce config input to one of the valid modes. Unknown values fall back to 'off'.""" | ||
| if raw is None: | ||
| return PERSISTENT_MODE_OFF | ||
| candidate = str(raw).strip().lower() | ||
| if candidate in VALID_PERSISTENT_MODES: | ||
| return candidate | ||
| return PERSISTENT_MODE_OFF |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.