From 8598598b1c78ecdbe56e90f6eb52ced82817cc9c Mon Sep 17 00:00:00 2001 From: finxo Date: Tue, 9 Jun 2026 12:26:46 +0200 Subject: [PATCH 01/12] feat: Add support for extracting and including referenced commit context in PR review threads --- docs/plugins/github/client-api.md | 21 +++ .../test_thread_resolution_operations.py | 31 ++++ .../tests/services/test_pr_service.py | 36 +++++ .../tests/unit/test_code_review_steps.py | 133 +++++++++++++++++- .../tests/unit/test_github_client.py | 26 ++++ .../clients/github_client.py | 15 ++ .../clients/services/pr_service.py | 73 ++++++++++ .../models/review_models.py | 11 ++ .../thread_resolution_operations.py | 15 ++ .../steps/code_review_steps.py | 100 ++++++++++++- 10 files changed, 453 insertions(+), 8 deletions(-) create mode 100644 plugins/titan-plugin-github/tests/operations/test_thread_resolution_operations.py diff --git a/docs/plugins/github/client-api.md b/docs/plugins/github/client-api.md index 17014100..01fee371 100644 --- a/docs/plugins/github/client-api.md +++ b/docs/plugins/github/client-api.md @@ -241,6 +241,27 @@ client.get_pr_commit_sha(123) - `pr_number`: Required. Pull request number. +### Read referenced commit context + +Returns compact remote context for a commit SHA mentioned in review discussion, +including changed files and a truncated patch excerpt suitable for AI prompts. + +**Call:** + +```python +client.get_commit_review_context( + "343e2e9", + max_files=3, + max_patch_chars=4000, +) +``` + +**Parameters:** + +- `commit_ref`: Required. Full or short commit SHA resolvable in the current repository. +- `max_files`: Optional. Maximum number of changed files to include in the returned context. +- `max_patch_chars`: Optional. Maximum combined patch excerpt size before truncation. + ### Merge a pull request Merges a pull request using the selected merge strategy. diff --git a/plugins/titan-plugin-github/tests/operations/test_thread_resolution_operations.py b/plugins/titan-plugin-github/tests/operations/test_thread_resolution_operations.py new file mode 100644 index 00000000..aa986d76 --- /dev/null +++ b/plugins/titan-plugin-github/tests/operations/test_thread_resolution_operations.py @@ -0,0 +1,31 @@ +from titan_plugin_github.models.review_models import ReferencedCommitContext, ThreadReviewContext +from titan_plugin_github.operations.thread_resolution_operations import build_thread_resolution_prompt + + +def test_build_thread_resolution_prompt_includes_referenced_commit_context(): + context = ThreadReviewContext( + thread_id="thread_123", + comment_id=10, + path="src/buttons.kt", + line=543, + main_comment_body="Please remove the default dialog state.", + main_comment_author="reviewer", + all_replies=[{"author": "author", "body": "Fixed in 343e2e9"}], + current_code_hunk="@@ -1,2 +1,2 @@\n-old\n+new\n", + referenced_commits=[ + ReferencedCommitContext( + sha="343e2e9d7402d0afccfd35a9ecc8e6ea341031c6", + abbreviated_sha="343e2e9", + message="remove default state value", + changed_files=["src/BaseDialog.kt"], + patch_excerpt="diff --git a/src/BaseDialog.kt b/src/BaseDialog.kt\n@@ -1 +1 @@\n-old\n+new\n", + ) + ], + ) + + prompt = build_thread_resolution_prompt([context]) + + assert "Referenced commits from replies" in prompt + assert "343e2e9" in prompt + assert "src/BaseDialog.kt" in prompt + assert "remove default state value" in prompt diff --git a/plugins/titan-plugin-github/tests/services/test_pr_service.py b/plugins/titan-plugin-github/tests/services/test_pr_service.py index 56733764..54f6b47a 100644 --- a/plugins/titan-plugin-github/tests/services/test_pr_service.py +++ b/plugins/titan-plugin-github/tests/services/test_pr_service.py @@ -190,3 +190,39 @@ def test_merge_pr_failure(pr_service, mock_gh_network): assert result.data.merged is False assert result.data.status_icon == "❌" assert "not mergeable" in result.data.message.lower() + + +def test_get_commit_review_context_success(pr_service, mock_gh_network): + """Test commit context retrieval for a referenced SHA.""" + mock_gh_network.run_command.return_value = json.dumps( + { + "sha": "343e2e9d7402d0afccfd35a9ecc8e6ea341031c6", + "commit": {"message": "remove default state value"}, + "files": [ + { + "filename": "freyja-core/src/main/kotlin/.../BaseDialog.kt", + "status": "modified", + "patch": "@@ -36,7 +35,7 @@\n-fun BaseDialog(dialogState: DialogState = remember { DialogState() })\n+fun BaseDialog(dialogState: DialogState)\n", + } + ], + } + ) + + result = pr_service.get_commit_review_context("343e2e9") + + assert isinstance(result, ClientSuccess) + assert result.data.abbreviated_sha == "343e2e9" + assert result.data.changed_files == ["freyja-core/src/main/kotlin/.../BaseDialog.kt"] + assert "remove default state value" == result.data.message + assert "diff --git a/freyja-core/src/main/kotlin/.../BaseDialog.kt" in result.data.patch_excerpt + assert "repos/test-owner/test-repo/commits/343e2e9" in mock_gh_network.run_command.call_args[0][0][1] + + +def test_get_commit_review_context_returns_parse_error_on_invalid_json(pr_service, mock_gh_network): + """Test invalid commit payload handling.""" + mock_gh_network.run_command.return_value = "not json" + + result = pr_service.get_commit_review_context("343e2e9") + + assert isinstance(result, ClientError) + assert result.error_code == "PARSE_ERROR" diff --git a/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py b/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py index e34fe268..fbd020d2 100644 --- a/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py +++ b/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py @@ -1,12 +1,16 @@ from unittest.mock import Mock -from titan_cli.core.result import ClientSuccess +from titan_cli.core.result import ClientError, ClientSuccess from titan_cli.engine import WorkflowContext from titan_cli.engine.results import Exit, Success -from titan_plugin_github.models.review_models import ChangeManifest, PullRequestManifest +from titan_plugin_github.models.review_models import ChangeManifest, PullRequestManifest, ReferencedCommitContext, ThreadReviewCandidate from titan_plugin_github.models.review_enums import FileChangeStatus -from titan_plugin_github.models.view import UIFileChange, UIPullRequest -from titan_plugin_github.steps.code_review_steps import fetch_pr_review_bundle, score_review_candidates +from titan_plugin_github.models.view import UIComment, UICommentThread, UIFileChange, UIPullRequest +from titan_plugin_github.steps.code_review_steps import ( + build_thread_review_contexts, + fetch_pr_review_bundle, + score_review_candidates, +) class _FakeTextual: @@ -197,3 +201,124 @@ def MockChangedFile(**kwargs): from titan_plugin_github.models.review_models import ChangedFileEntry return ChangedFileEntry(**kwargs) + + +def _make_thread(*, reply_body: str, path: str, line: int, body: str) -> UICommentThread: + return UICommentThread( + thread_id="thread_123", + main_comment=UIComment( + id=10, + body=body, + author_login="reviewer", + author_name="Reviewer", + formatted_date="", + path=path, + line=line, + diff_hunk="@@ -541,3 +541,3 @@\n-fun ButtonDialog(dialogState: DialogState = rememberDialogState(false))\n+fun ButtonDialog(dialogState: DialogState)\n", + ), + replies=[ + UIComment( + id=11, + body=reply_body, + author_login="author", + author_name="Author", + formatted_date="", + path=path, + line=line, + ) + ], + is_resolved=False, + is_outdated=False, + ) + + +def test_build_thread_review_contexts_includes_referenced_commit_contexts(): + ctx = WorkflowContext(secrets=Mock()) + ctx.textual = _FakeTextual() + ctx.github = Mock() + ctx.data["thread_review_candidates"] = [ + ThreadReviewCandidate( + thread_id="thread_123", + path="freyja-core/src/main/kotlin/es/masorange/freyja/core/components/buttons/Buttons.kt", + line=543, + main_comment_body="Please fix the dialog state wiring", + main_comment_author="reviewer", + replies_count=1, + last_reply_author="author", + last_reply_body="Fixed in 343e2e9d7402d0afccfd35a9ecc8e6ea341031c6", + ) + ] + ctx.data["review_threads"] = [ + _make_thread( + reply_body="Fixed in 343e2e9d7402d0afccfd35a9ecc8e6ea341031c6", + path="freyja-core/src/main/kotlin/es/masorange/freyja/core/components/buttons/Buttons.kt", + line=543, + body="Please fix the dialog state wiring", + ) + ] + ctx.data["review_diff"] = ( + "diff --git a/freyja-core/src/main/kotlin/es/masorange/freyja/core/components/buttons/Buttons.kt " + "b/freyja-core/src/main/kotlin/es/masorange/freyja/core/components/buttons/Buttons.kt\n" + "@@ -541,3 +541,3 @@\n" + "-fun ButtonDialog(dialogState: DialogState = rememberDialogState(false))\n" + "+fun ButtonDialog(dialogState: DialogState)\n" + ) + ctx.github.get_commit_review_context.return_value = ClientSuccess( + data=ReferencedCommitContext( + sha="343e2e9d7402d0afccfd35a9ecc8e6ea341031c6", + abbreviated_sha="343e2e9", + message="remove default state value", + changed_files=["freyja-core/src/main/kotlin/.../BaseDialog.kt"], + patch_excerpt="diff --git a/freyja-core/src/main/kotlin/.../BaseDialog.kt b/freyja-core/src/main/kotlin/.../BaseDialog.kt", + ), + message="ok", + ) + + result = build_thread_review_contexts(ctx) + + assert isinstance(result, Success) + contexts = ctx.data["thread_review_contexts"] + assert len(contexts) == 1 + assert contexts[0].referenced_commits[0].abbreviated_sha == "343e2e9" + ctx.github.get_commit_review_context.assert_called_once_with( + "343e2e9d7402d0afccfd35a9ecc8e6ea341031c6", + max_files=3, + max_patch_chars=4000, + ) + + +def test_build_thread_review_contexts_ignores_unavailable_referenced_commits(): + ctx = WorkflowContext(secrets=Mock()) + ctx.textual = _FakeTextual() + ctx.github = Mock() + ctx.data["thread_review_candidates"] = [ + ThreadReviewCandidate( + thread_id="thread_123", + path="src/main.py", + line=42, + main_comment_body="Please fix this", + main_comment_author="reviewer", + replies_count=1, + last_reply_author="author", + last_reply_body="Addressed in deadbee", + ) + ] + ctx.data["review_threads"] = [ + _make_thread( + reply_body="Addressed in deadbee", + path="src/main.py", + line=42, + body="Please fix this", + ) + ] + ctx.data["review_diff"] = "diff --git a/src/main.py b/src/main.py\n@@ -40,1 +40,1 @@\n-old\n+new\n" + ctx.github.get_commit_review_context.return_value = ClientError( + error_message="commit not found", + error_code="API_ERROR", + ) + + result = build_thread_review_contexts(ctx) + + assert isinstance(result, Success) + contexts = ctx.data["thread_review_contexts"] + assert contexts[0].referenced_commits == [] diff --git a/plugins/titan-plugin-github/tests/unit/test_github_client.py b/plugins/titan-plugin-github/tests/unit/test_github_client.py index 1267acaf..c7f82fba 100644 --- a/plugins/titan-plugin-github/tests/unit/test_github_client.py +++ b/plugins/titan-plugin-github/tests/unit/test_github_client.py @@ -218,3 +218,29 @@ def test_resolve_review_thread_delegates_to_service(github_client): assert isinstance(result, ClientSuccess) github_client._review_service.resolve_review_thread.assert_called_once_with("thread_123") + + +def test_get_commit_review_context_delegates_to_service(github_client): + """Test that commit review context delegates to PRService.""" + from titan_plugin_github.models.review_models import ReferencedCommitContext + + commit_context = ReferencedCommitContext( + sha="343e2e9d7402d0afccfd35a9ecc8e6ea341031c6", + abbreviated_sha="343e2e9", + message="remove default state value", + changed_files=["src/BaseDialog.kt"], + patch_excerpt="diff --git a/src/BaseDialog.kt b/src/BaseDialog.kt", + ) + github_client._pr_service.get_commit_review_context = Mock( + return_value=ClientSuccess(data=commit_context, message="Commit context retrieved") + ) + + result = github_client.get_commit_review_context("343e2e9", max_files=2, max_patch_chars=1200) + + assert isinstance(result, ClientSuccess) + assert result.data.abbreviated_sha == "343e2e9" + github_client._pr_service.get_commit_review_context.assert_called_once_with( + "343e2e9", + max_files=2, + max_patch_chars=1200, + ) diff --git a/plugins/titan-plugin-github/titan_plugin_github/clients/github_client.py b/plugins/titan-plugin-github/titan_plugin_github/clients/github_client.py index 9decd7e0..b915e152 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/clients/github_client.py +++ b/plugins/titan-plugin-github/titan_plugin_github/clients/github_client.py @@ -15,6 +15,7 @@ from .network import GHNetwork, GraphQLNetwork from .services import PRService, ReviewService, IssueService, TeamService, ReleaseService +from ..models.review_models import ReferencedCommitContext from ..models.view import UIPullRequest, UICommentThread, UIIssue, UIPRMergeResult, UIReview, UIFileChange, UIPRCreated, UIRelease @@ -191,6 +192,20 @@ def get_pr_commit_sha(self, pr_number: int) -> ClientResult[str]: """Get the latest commit SHA for a PR.""" return self._pr_service.get_pr_commit_sha(pr_number) + def get_commit_review_context( + self, + commit_ref: str, + *, + max_files: int = 3, + max_patch_chars: int = 4000, + ) -> ClientResult[ReferencedCommitContext]: + """Get a compact remote context for a referenced commit.""" + return self._pr_service.get_commit_review_context( + commit_ref, + max_files=max_files, + max_patch_chars=max_patch_chars, + ) + # ============================================================================ # Review Operations # ============================================================================ diff --git a/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py b/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py index af2c6873..f6e6b40f 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py +++ b/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py @@ -14,6 +14,7 @@ from ..network import GHNetwork from ...models.network.rest import NetworkPullRequest, NetworkPRMergeResult, NetworkPRFile, NetworkPRCreated +from ...models.review_models import ReferencedCommitContext from ...models.view import UIPullRequest, UIPRMergeResult, UIFileChange, UIPRCreated from ...models.mappers import from_rest_pr, from_network_pr_merge_result, from_network_pr_file, from_network_pr_created from ...exceptions import GitHubAPIError @@ -683,3 +684,75 @@ def get_pr_commit_sha(self, pr_number: int) -> ClientResult[str]: ) except GitHubAPIError as e: return ClientError(error_message=str(e), error_code="API_ERROR") + + @log_client_operation() + def get_commit_review_context( + self, + commit_ref: str, + *, + max_files: int = 3, + max_patch_chars: int = 4000, + ) -> ClientResult[ReferencedCommitContext]: + """Get a compact review context for a referenced commit.""" + try: + output = self.gh.run_command( + ["api", f"repos/{self.gh.get_repo_string()}/commits/{commit_ref}"] + ) + data = json.loads(output) + + sha = str(data["sha"]) + commit_message = self._truncate_text( + str(data.get("commit", {}).get("message", "")).strip(), + 600, + ) + files = data.get("files", []) + changed_files: list[str] = [] + patch_sections: list[str] = [] + + for file_data in files[:max_files]: + filename = str(file_data.get("filename", "")).strip() + if not filename: + continue + + changed_files.append(filename) + + patch = file_data.get("patch") + if not patch: + continue + + status = str(file_data.get("status", "modified")).strip() + patch_sections.append( + f"diff --git a/{filename} b/{filename}\n" + f"# status: {status}\n" + f"{patch.strip()}" + ) + + patch_excerpt = "\n\n".join(patch_sections).strip() or None + if patch_excerpt: + patch_excerpt = self._truncate_text(patch_excerpt, max_patch_chars) + + return ClientSuccess( + data=ReferencedCommitContext( + sha=sha, + abbreviated_sha=sha[:7], + message=commit_message, + changed_files=changed_files, + patch_excerpt=patch_excerpt, + ), + message=f"Commit context retrieved for {sha[:7]}", + ) + + except (json.JSONDecodeError, KeyError, TypeError, ValueError) as e: + return ClientError( + error_message=f"Failed to parse commit context: {e}", + error_code="PARSE_ERROR", + ) + except GitHubAPIError as e: + return ClientError(error_message=str(e), error_code="API_ERROR") + + @staticmethod + def _truncate_text(text: str, max_chars: int) -> str: + """Trim large gh payload sections without hiding that they were truncated.""" + if len(text) <= max_chars: + return text + return text[: max_chars - 15].rstrip() + "\n... [truncated]" diff --git a/plugins/titan-plugin-github/titan_plugin_github/models/review_models.py b/plugins/titan-plugin-github/titan_plugin_github/models/review_models.py index 8cdec36d..fa88e80e 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/models/review_models.py +++ b/plugins/titan-plugin-github/titan_plugin_github/models/review_models.py @@ -250,6 +250,16 @@ class ThreadReviewCandidate(BaseModel): is_outdated: bool = False +class ReferencedCommitContext(BaseModel): + """Remote commit context referenced from a review-thread reply.""" + + sha: str + abbreviated_sha: str + message: str = "" + changed_files: list[str] = Field(default_factory=list) + patch_excerpt: Optional[str] = None + + class ThreadReviewContext(BaseModel): """Enriched context for AI to decide what to do with a thread.""" @@ -261,6 +271,7 @@ class ThreadReviewContext(BaseModel): main_comment_author: str all_replies: list[dict] = Field(default_factory=list) current_code_hunk: Optional[str] = None + referenced_commits: list[ReferencedCommitContext] = Field(default_factory=list) is_outdated: bool = False diff --git a/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py b/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py index ef7a428d..fdb7ef94 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py +++ b/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py @@ -244,6 +244,21 @@ def _threads_to_text(contexts: list[ThreadReviewContext]) -> str: else: parts.append("\n*(code context not available — file may be deleted or line not in diff)*") + if ctx.referenced_commits: + parts.append("\n**Referenced commits from replies:**") + for commit in ctx.referenced_commits: + parts.append(f"- Commit `{commit.abbreviated_sha}`") + if commit.message: + parts.append(f" Message: {commit.message}") + if commit.changed_files: + parts.append(" Files: " + ", ".join(commit.changed_files)) + if commit.patch_excerpt: + parts.append(" Patch excerpt:") + parts.append(" ```diff") + for line in commit.patch_excerpt.splitlines(): + parts.append(f" {line}") + parts.append(" ```") + parts.append("") return "\n".join(parts) diff --git a/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py b/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py index 0d02a2c9..b23cec0e 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py +++ b/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py @@ -17,7 +17,11 @@ from ..managers.diff_context_manager import get_or_create_diff_manager from ..models.review_enums import FileReadMode, ReviewActionType, ReviewStrategyType, ThreadDecisionType -from ..models.review_models import PRClassification, ReviewActionProposal +from ..models.review_models import ( + PRClassification, + ReferencedCommitContext, + ReviewActionProposal, +) from ..models.review_profile_models import ReviewProfile from ..models.view import UICommentThread, UIPullRequest from ..operations.code_review_operations import ( @@ -55,11 +59,15 @@ _PROMPT_PREVIEW_CHARS = 2000 _RESPONSE_PREVIEW_CHARS = 1500 +_COMMIT_SHA_RE = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE) _STRONG_API_CLAIM_RE = re.compile( r"(does not accept|does not provide|does not compile|overload|signature|parameter(?:s)? .* not)", re.IGNORECASE, ) _CENTRAL_PATH_HINTS = ("/utils/", "/configuration/", "/interceptors/", "/base/", "Utils.kt", "Configuration.kt") +_MAX_REFERENCED_COMMITS_PER_THREAD = 3 +_MAX_REFERENCED_COMMIT_FILES = 3 +_MAX_REFERENCED_COMMIT_PATCH_CHARS = 4000 def _preview_edges(text: str, limit: int) -> tuple[str, str]: @@ -118,6 +126,69 @@ def _log_ai_response(step_name: str, cli_name: str, stdout: str, stderr: str, ex ) +def _extract_referenced_commit_shas(reply_bodies: list[str]) -> list[str]: + """Collect distinct SHA-like tokens mentioned in reply bodies.""" + seen: set[str] = set() + shas: list[str] = [] + + for body in reply_bodies: + for match in _COMMIT_SHA_RE.findall(body or ""): + sha = match.lower() + if sha in seen: + continue + seen.add(sha) + shas.append(sha) + + return shas + + +def _load_referenced_commit_contexts( + ctx: WorkflowContext, + threads: list[UICommentThread], +) -> dict[str, list[ReferencedCommitContext]]: + """Fetch compact remote commit context for SHA references in review-thread replies.""" + if not ctx.github: + return {} + + commit_cache: dict[str, ReferencedCommitContext | None] = {} + contexts_by_thread: dict[str, list[ReferencedCommitContext]] = {} + + for thread in threads: + reply_bodies = [reply.body for reply in thread.replies] + referenced_shas = _extract_referenced_commit_shas(reply_bodies) + if not referenced_shas: + continue + + referenced_contexts: list[ReferencedCommitContext] = [] + for sha in referenced_shas[:_MAX_REFERENCED_COMMITS_PER_THREAD]: + if sha not in commit_cache: + result = ctx.github.get_commit_review_context( + sha, + max_files=_MAX_REFERENCED_COMMIT_FILES, + max_patch_chars=_MAX_REFERENCED_COMMIT_PATCH_CHARS, + ) + match result: + case ClientSuccess(data=commit_context): + commit_cache[sha] = commit_context + case ClientError(error_message=err): + logger.debug( + "referenced_commit_context_unavailable", + thread_id=thread.thread_id, + sha=sha, + error=err, + ) + commit_cache[sha] = None + + commit_context = commit_cache.get(sha) + if commit_context is not None: + referenced_contexts.append(commit_context) + + if referenced_contexts: + contexts_by_thread[thread.thread_id] = referenced_contexts + + return contexts_by_thread + + def _build_visible_file_context_map(batches: list) -> dict[str, str]: """Flatten current review batches into a path -> visible text map.""" file_map: dict[str, str] = {} @@ -2521,14 +2592,18 @@ def build_thread_review_contexts(ctx: WorkflowContext) -> WorkflowResult: """ Enrich thread candidates with diff hunk context and full reply history. - For each candidate, extracts the diff hunk near the commented line and - collects all replies from the full UICommentThread object. + For each candidate, extracts the diff hunk near the commented line, + collects all replies from the full UICommentThread object, and attaches + remote context for commit SHAs referenced in those replies. Requires (from ctx.data): thread_review_candidates (List[ThreadReviewCandidate]) review_threads (List[UICommentThread]): For extracting reply history review_diff (str): Full PR unified diff + Requires: + ctx.github: Optional GitHub client used to inspect referenced commits. + Outputs (saved to ctx.data): thread_review_contexts (List[ThreadReviewContext]) @@ -2550,9 +2625,26 @@ def build_thread_review_contexts(ctx: WorkflowContext) -> WorkflowResult: return Skip("No thread_review_candidates in context") contexts = build_thread_review_contexts_operation(candidates, threads, diff) + commit_contexts_by_thread = _load_referenced_commit_contexts(ctx, threads) + if commit_contexts_by_thread: + contexts = [ + context.model_copy( + update={ + "referenced_commits": commit_contexts_by_thread.get( + context.thread_id, + [], + ) + } + ) + for context in contexts + ] ctx.data["thread_review_contexts"] = contexts - ctx.textual.success_text(f"✓ {len(contexts)} thread context(s) built") + referenced_commit_count = sum(len(context.referenced_commits) for context in contexts) + summary = f"✓ {len(contexts)} thread context(s) built" + if referenced_commit_count: + summary += f" ({referenced_commit_count} referenced commit context(s))" + ctx.textual.success_text(summary) ctx.textual.end_step("success") return Success("Thread contexts built", metadata={"thread_review_contexts_count": len(contexts)}) From ba89f0d5193a38a967feda0632b0ca5c7f442fe3 Mon Sep 17 00:00:00 2001 From: finxo Date: Tue, 9 Jun 2026 15:25:24 +0200 Subject: [PATCH 02/12] feat: Filter thread review candidates to only include the current user's threads --- .../generated/github-step-reference.md | 8 ++ docs/plugins/github/workflow-steps.md | 6 +- .../test_thread_resolution_operations.py | 58 ++++++++++++++- .../tests/unit/test_code_review_steps.py | 74 ++++++++++++++++++- .../thread_resolution_operations.py | 4 + .../steps/code_review_steps.py | 32 +++++++- 6 files changed, 174 insertions(+), 8 deletions(-) diff --git a/docs/plugins/generated/github-step-reference.md b/docs/plugins/generated/github-step-reference.md index 01b06ec5..52a9090b 100644 --- a/docs/plugins/generated/github-step-reference.md +++ b/docs/plugins/generated/github-step-reference.md @@ -1510,6 +1510,14 @@ Select open inline threads worth AI analysis. **Available to later steps:** `thread_review_candidates (List[ThreadReviewCandidate])` +**Inputs (from ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| review_threads (List[UICommentThread]) | - | - | +| review_pr (UIPullRequest) | - | - | +| review_current_user (str) | - | Authenticated GitHub login running Titan | + **Outputs (saved to ctx.data)** | Name | Type | Description | diff --git a/docs/plugins/github/workflow-steps.md b/docs/plugins/github/workflow-steps.md index 9e8cfa98..b9776bda 100644 --- a/docs/plugins/github/workflow-steps.md +++ b/docs/plugins/github/workflow-steps.md @@ -388,7 +388,11 @@ How to read these contracts: **Inputs (from ctx.data)** - None documented. + | Name | Type | Description | + |------|------|-------------| + | review_threads (List[UICommentThread]) | - | - | + | review_pr (UIPullRequest) | - | - | + | review_current_user (str) | - | Authenticated GitHub login running Titan | **Outputs (saved to ctx.data)** diff --git a/plugins/titan-plugin-github/tests/operations/test_thread_resolution_operations.py b/plugins/titan-plugin-github/tests/operations/test_thread_resolution_operations.py index aa986d76..42c596d3 100644 --- a/plugins/titan-plugin-github/tests/operations/test_thread_resolution_operations.py +++ b/plugins/titan-plugin-github/tests/operations/test_thread_resolution_operations.py @@ -1,5 +1,61 @@ from titan_plugin_github.models.review_models import ReferencedCommitContext, ThreadReviewContext -from titan_plugin_github.operations.thread_resolution_operations import build_thread_resolution_prompt +from titan_plugin_github.models.view import UIComment, UICommentThread +from titan_plugin_github.operations.thread_resolution_operations import ( + build_thread_resolution_prompt, + build_thread_review_candidates, +) + + +def _make_thread(*, main_author: str, reply_author: str) -> UICommentThread: + return UICommentThread( + thread_id=f"thread_{main_author}_{reply_author}", + main_comment=UIComment( + id=10, + body="Please fix this", + author_login=main_author, + author_name=main_author, + formatted_date="", + path="src/main.py", + line=42, + ), + replies=[ + UIComment( + id=11, + body="Fixed", + author_login=reply_author, + author_name=reply_author, + formatted_date="", + path="src/main.py", + line=42, + ) + ], + is_resolved=False, + is_outdated=False, + ) + + +def test_build_thread_review_candidates_only_includes_current_users_threads(): + candidates = build_thread_review_candidates( + [ + _make_thread(main_author="current-reviewer", reply_author="pr-author"), + _make_thread(main_author="other-reviewer", reply_author="pr-author"), + ], + pr_author="pr-author", + reviewer_login="current-reviewer", + ) + + assert len(candidates) == 1 + assert candidates[0].main_comment_author == "current-reviewer" + + +def test_build_thread_review_candidates_excludes_threads_without_pr_author_last_reply(): + candidates = build_thread_review_candidates( + [_make_thread(main_author="current-reviewer", reply_author="other-reviewer")], + pr_author="pr-author", + reviewer_login="current-reviewer", + ) + + assert candidates == [] def test_build_thread_resolution_prompt_includes_referenced_commit_context(): diff --git a/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py b/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py index fbd020d2..4c7da3ef 100644 --- a/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py +++ b/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py @@ -2,11 +2,12 @@ from titan_cli.core.result import ClientError, ClientSuccess from titan_cli.engine import WorkflowContext -from titan_cli.engine.results import Exit, Success +from titan_cli.engine.results import Error, Exit, Success from titan_plugin_github.models.review_models import ChangeManifest, PullRequestManifest, ReferencedCommitContext, ThreadReviewCandidate from titan_plugin_github.models.review_enums import FileChangeStatus from titan_plugin_github.models.view import UIComment, UICommentThread, UIFileChange, UIPullRequest from titan_plugin_github.steps.code_review_steps import ( + build_thread_review_candidates, build_thread_review_contexts, fetch_pr_review_bundle, score_review_candidates, @@ -52,14 +53,14 @@ def loading(self, _text): return self._Loading() -def _make_pr(*, is_cross_repository: bool) -> UIPullRequest: +def _make_pr(*, is_cross_repository: bool, author_name: str = "gabrielglbh") -> UIPullRequest: return UIPullRequest( number=223, title="Poeditor plugin implementation", body="Body", status_icon="🟢", state="OPEN", - author_name="gabrielglbh", + author_name=author_name, head_ref="poeditor-plugin", base_ref="master", branch_info="poeditor-plugin → master", @@ -100,6 +101,7 @@ def _make_context(sample_pr: UIPullRequest) -> WorkflowContext: ctx.github.get_pr_commit_sha.return_value = ClientSuccess(data="abc123", message="ok") ctx.github.get_pr_review_threads.return_value = ClientSuccess(data=[], message="ok") ctx.github.get_pr_general_comments.return_value = ClientSuccess(data=[], message="ok") + ctx.github.get_current_user.return_value = ClientSuccess(data="reviewer", message="ok") ctx.github.get_pr_template.return_value = None return ctx @@ -166,6 +168,72 @@ def test_fetch_pr_review_bundle_exits_when_pr_has_no_files_and_no_diff(): assert result.message == "Empty PR diff" +def test_fetch_pr_review_bundle_includes_current_github_user(): + pr = _make_pr(is_cross_repository=True) + ctx = _make_context(pr) + ctx.github.get_pr_diff.return_value = ClientSuccess(data="diff --git a/foo b/foo", message="ok") + + result = fetch_pr_review_bundle(ctx) + + assert isinstance(result, Success) + assert result.metadata["review_current_user"] == "reviewer" + ctx.github.get_current_user.assert_called_once_with() + + +def test_build_thread_review_candidates_filters_to_current_user_threads(): + ctx = WorkflowContext(secrets=Mock()) + ctx.textual = _FakeTextual() + ctx.data["review_pr"] = _make_pr(is_cross_repository=False, author_name="author") + ctx.data["review_current_user"] = "reviewer" + ctx.data["review_threads"] = [ + _make_thread(reply_body="Fixed", path="src/main.py", line=42, body="Please fix this"), + UICommentThread( + thread_id="thread_456", + main_comment=UIComment( + id=20, + body="Please fix this too", + author_login="other-reviewer", + author_name="Other Reviewer", + formatted_date="", + path="src/other.py", + line=10, + ), + replies=[ + UIComment( + id=21, + body="Done", + author_login="gabrielglbh", + author_name="gabrielglbh", + formatted_date="", + path="src/other.py", + line=10, + ) + ], + is_resolved=False, + is_outdated=False, + ), + ] + + result = build_thread_review_candidates(ctx) + + assert isinstance(result, Success) + candidates = ctx.data["thread_review_candidates"] + assert len(candidates) == 1 + assert candidates[0].main_comment_author == "reviewer" + + +def test_build_thread_review_candidates_errors_without_current_user(): + ctx = WorkflowContext(secrets=Mock()) + ctx.textual = _FakeTextual() + ctx.data["review_pr"] = _make_pr(is_cross_repository=False) + ctx.data["review_threads"] = [] + + result = build_thread_review_candidates(ctx) + + assert isinstance(result, Error) + assert result.message == "Current GitHub user not available" + + def test_score_review_candidates_exits_when_no_reviewable_candidates_remain(): ctx = WorkflowContext(secrets=Mock()) ctx.textual = _FakeTextual() diff --git a/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py b/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py index fdb7ef94..6c790168 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py +++ b/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py @@ -28,6 +28,7 @@ def build_thread_review_candidates( threads: list[UICommentThread], pr_author: str, + reviewer_login: str, ) -> list[ThreadReviewCandidate]: """ Filter threads worth AI analysis from the full list of open PR threads. @@ -40,6 +41,7 @@ def build_thread_review_candidates( Args: threads: All unresolved inline review threads from fetch_pr_review_bundle pr_author: GitHub login of the PR author + reviewer_login: GitHub login of the reviewer running Titan Returns: ThreadReviewCandidate list ready for context enrichment @@ -50,6 +52,8 @@ def build_thread_review_candidates( continue if thread.is_resolved: continue + if thread.main_comment.author_login != reviewer_login: + continue # Skip if no replies — reviewer is waiting for author response if not thread.replies: diff --git a/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py b/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py index b23cec0e..adb32211 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py +++ b/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py @@ -829,6 +829,7 @@ def fetch_pr_review_bundle(ctx: WorkflowContext) -> WorkflowResult: # Fetch inline review threads and general comments separately review_threads = [] general_comments = [] + review_current_user = None with ctx.textual.loading("Fetching existing review comments..."): threads_result = ctx.github.get_pr_review_threads(pr_number, include_resolved=True) match threads_result: @@ -844,6 +845,15 @@ def fetch_pr_review_bundle(ctx: WorkflowContext) -> WorkflowResult: case ClientError(): pass + current_user_result = ctx.github.get_current_user() + match current_user_result: + case ClientSuccess(data=current_user): + review_current_user = current_user + case ClientError(error_message=err): + ctx.textual.error_text(f"Failed to get current user: {err}") + ctx.textual.end_step("error") + return Error(f"Failed to get current user: {err}") + ctx.textual.dim_text( f"{len(changed_file_paths)} files · {formatted_summary} · " f"{len(review_threads)} review thread(s) · {len(general_comments)} general comment(s)" @@ -864,6 +874,7 @@ def fetch_pr_review_bundle(ctx: WorkflowContext) -> WorkflowResult: "review_commit_sha": commit_sha, "review_threads": review_threads, "review_general_comments": general_comments, + "review_current_user": review_current_user, "pr_template": pr_template, }, ) @@ -2552,6 +2563,7 @@ def build_thread_review_candidates(ctx: WorkflowContext) -> WorkflowResult: Requires (from ctx.data): review_threads (List[UICommentThread]): Unresolved inline review threads review_pr (UIPullRequest): PR object with author info + review_current_user (str): GitHub login running Titan Outputs (saved to ctx.data): thread_review_candidates (List[ThreadReviewCandidate]) @@ -2566,24 +2578,38 @@ def build_thread_review_candidates(ctx: WorkflowContext) -> WorkflowResult: threads = ctx.get("review_threads", []) pr = ctx.get("review_pr") + review_current_user = ctx.get("review_current_user") if not pr: ctx.textual.dim_text("No PR info available") ctx.textual.end_step("skip") return Skip("No PR data in context") - candidates = build_thread_review_candidates_operation(threads, pr.author_name) + if not review_current_user: + ctx.textual.error_text("Current GitHub user not available") + ctx.textual.end_step("error") + return Error("Current GitHub user not available") + + candidates = build_thread_review_candidates_operation( + threads, + pr.author_name, + review_current_user, + ) if not candidates: if not threads: ctx.textual.dim_text("No open inline threads on this PR") else: - ctx.textual.dim_text("Author has not replied to review threads yet (waiting for responses)") + ctx.textual.dim_text( + f"No open threads created by @{review_current_user} with author replies yet" + ) ctx.textual.end_step("skip") return Skip("No threads to review") ctx.data["thread_review_candidates"] = candidates - ctx.textual.success_text(f"✓ {len(candidates)} thread(s) with author replies selected") + ctx.textual.success_text( + f"✓ {len(candidates)} thread(s) created by @{review_current_user} with author replies selected" + ) ctx.textual.end_step("success") return Success("Thread candidates built", metadata={"thread_review_candidates_count": len(candidates)}) From e2b1bb98d25e49a65f5463f9083c8357482feb8b Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 8 Jul 2026 11:14:06 +0200 Subject: [PATCH 03/12] Fix: code_review_steps.py By: finxo --- .../titan_plugin_github/steps/code_review_steps.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py b/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py index adb32211..a0a5ce9c 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py +++ b/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py @@ -2651,7 +2651,9 @@ def build_thread_review_contexts(ctx: WorkflowContext) -> WorkflowResult: return Skip("No thread_review_candidates in context") contexts = build_thread_review_contexts_operation(candidates, threads, diff) - commit_contexts_by_thread = _load_referenced_commit_contexts(ctx, threads) + candidate_ids = {candidate.thread_id for candidate in candidates} + candidate_threads = [thread for thread in threads if thread.thread_id in candidate_ids] + commit_contexts_by_thread = _load_referenced_commit_contexts(ctx, candidate_threads) if commit_contexts_by_thread: contexts = [ context.model_copy( From 3e40ab8dfecea33cf44cd3c272efd392f59ee931 Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 8 Jul 2026 11:15:20 +0200 Subject: [PATCH 04/12] Fix: test_pr_service.py By: finxo --- .../tests/services/test_pr_service.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/plugins/titan-plugin-github/tests/services/test_pr_service.py b/plugins/titan-plugin-github/tests/services/test_pr_service.py index 54f6b47a..3d5608b2 100644 --- a/plugins/titan-plugin-github/tests/services/test_pr_service.py +++ b/plugins/titan-plugin-github/tests/services/test_pr_service.py @@ -226,3 +226,13 @@ def test_get_commit_review_context_returns_parse_error_on_invalid_json(pr_servic assert isinstance(result, ClientError) assert result.error_code == "PARSE_ERROR" + + +def test_get_commit_review_context_returns_api_error_on_network_failure(pr_service, mock_gh_network): + """Test GitHub API failure handling.""" + mock_gh_network.run_command.side_effect = GitHubAPIError("Not Found") + + result = pr_service.get_commit_review_context("343e2e9") + + assert isinstance(result, ClientError) + assert result.error_code == "API_ERROR" From 71ababe0dc8ce202495535ba9b4a9abff6f703a8 Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 8 Jul 2026 12:01:04 +0200 Subject: [PATCH 05/12] Fix: pr_service.py By: finxo --- .../tests/services/test_pr_service.py | 18 ++++++ .../tests/unit/test_code_review_steps.py | 64 ++++++++++++++++++- .../tests/unit/test_github_client.py | 2 + .../clients/github_client.py | 4 ++ .../clients/services/pr_service.py | 18 +++++- .../models/mappers/pr_mapper.py | 1 + .../models/network/rest/pull_request.py | 2 + .../titan_plugin_github/models/view.py | 1 + .../steps/code_review_steps.py | 18 +++++- 9 files changed, 121 insertions(+), 7 deletions(-) diff --git a/plugins/titan-plugin-github/tests/services/test_pr_service.py b/plugins/titan-plugin-github/tests/services/test_pr_service.py index 3d5608b2..a95a8912 100644 --- a/plugins/titan-plugin-github/tests/services/test_pr_service.py +++ b/plugins/titan-plugin-github/tests/services/test_pr_service.py @@ -218,6 +218,24 @@ def test_get_commit_review_context_success(pr_service, mock_gh_network): assert "repos/test-owner/test-repo/commits/343e2e9" in mock_gh_network.run_command.call_args[0][0][1] +def test_get_commit_review_context_uses_head_repo_when_provided(pr_service, mock_gh_network): + """Test that repo_owner/repo_name override the configured base repo (fork PRs).""" + mock_gh_network.run_command.return_value = json.dumps( + { + "sha": "343e2e9d7402d0afccfd35a9ecc8e6ea341031c6", + "commit": {"message": "remove default state value"}, + "files": [], + } + ) + + result = pr_service.get_commit_review_context( + "343e2e9", repo_owner="fork-owner", repo_name="fork-repo" + ) + + assert isinstance(result, ClientSuccess) + assert "repos/fork-owner/fork-repo/commits/343e2e9" in mock_gh_network.run_command.call_args[0][0][1] + + def test_get_commit_review_context_returns_parse_error_on_invalid_json(pr_service, mock_gh_network): """Test invalid commit payload handling.""" mock_gh_network.run_command.return_value = "not json" diff --git a/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py b/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py index 4c7da3ef..b1b2a2f5 100644 --- a/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py +++ b/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py @@ -53,7 +53,9 @@ def loading(self, _text): return self._Loading() -def _make_pr(*, is_cross_repository: bool, author_name: str = "gabrielglbh") -> UIPullRequest: +def _make_pr( + *, is_cross_repository: bool, author_name: str = "forkuser", head_repository_name: str = "some-repo" +) -> UIPullRequest: return UIPullRequest( number=223, title="Poeditor plugin implementation", @@ -73,7 +75,8 @@ def _make_pr(*, is_cross_repository: bool, author_name: str = "gabrielglbh") -> formatted_created_at="", formatted_updated_at="", is_cross_repository=is_cross_repository, - head_repository_owner="gabrielglbh" if is_cross_repository else "masorange", + head_repository_owner="forkuser" if is_cross_repository else "base-org", + head_repository_name=head_repository_name if is_cross_repository else None, ) @@ -350,6 +353,63 @@ def test_build_thread_review_contexts_includes_referenced_commit_contexts(): assert contexts[0].referenced_commits[0].abbreviated_sha == "343e2e9" ctx.github.get_commit_review_context.assert_called_once_with( "343e2e9d7402d0afccfd35a9ecc8e6ea341031c6", + repo_owner=None, + repo_name=None, + max_files=3, + max_patch_chars=4000, + ) + + +def test_build_thread_review_contexts_resolves_referenced_commits_against_fork_head_repo(): + ctx = WorkflowContext(secrets=Mock()) + ctx.textual = _FakeTextual() + ctx.github = Mock() + ctx.data["review_pr"] = _make_pr(is_cross_repository=True, head_repository_name="fork-repo") + ctx.data["thread_review_candidates"] = [ + ThreadReviewCandidate( + thread_id="thread_123", + path="freyja-core/src/main/kotlin/es/masorange/freyja/core/components/buttons/Buttons.kt", + line=543, + main_comment_body="Please fix the dialog state wiring", + main_comment_author="reviewer", + replies_count=1, + last_reply_author="author", + last_reply_body="Fixed in 343e2e9d7402d0afccfd35a9ecc8e6ea341031c6", + ) + ] + ctx.data["review_threads"] = [ + _make_thread( + reply_body="Fixed in 343e2e9d7402d0afccfd35a9ecc8e6ea341031c6", + path="freyja-core/src/main/kotlin/es/masorange/freyja/core/components/buttons/Buttons.kt", + line=543, + body="Please fix the dialog state wiring", + ) + ] + ctx.data["review_diff"] = ( + "diff --git a/freyja-core/src/main/kotlin/es/masorange/freyja/core/components/buttons/Buttons.kt " + "b/freyja-core/src/main/kotlin/es/masorange/freyja/core/components/buttons/Buttons.kt\n" + "@@ -541,3 +541,3 @@\n" + "-fun ButtonDialog(dialogState: DialogState = rememberDialogState(false))\n" + "+fun ButtonDialog(dialogState: DialogState)\n" + ) + ctx.github.get_commit_review_context.return_value = ClientSuccess( + data=ReferencedCommitContext( + sha="343e2e9d7402d0afccfd35a9ecc8e6ea341031c6", + abbreviated_sha="343e2e9", + message="remove default state value", + changed_files=["freyja-core/src/main/kotlin/.../BaseDialog.kt"], + patch_excerpt="diff --git a/freyja-core/src/main/kotlin/.../BaseDialog.kt b/freyja-core/src/main/kotlin/.../BaseDialog.kt", + ), + message="ok", + ) + + result = build_thread_review_contexts(ctx) + + assert isinstance(result, Success) + ctx.github.get_commit_review_context.assert_called_once_with( + "343e2e9d7402d0afccfd35a9ecc8e6ea341031c6", + repo_owner="forkuser", + repo_name="fork-repo", max_files=3, max_patch_chars=4000, ) diff --git a/plugins/titan-plugin-github/tests/unit/test_github_client.py b/plugins/titan-plugin-github/tests/unit/test_github_client.py index c7f82fba..493f6341 100644 --- a/plugins/titan-plugin-github/tests/unit/test_github_client.py +++ b/plugins/titan-plugin-github/tests/unit/test_github_client.py @@ -241,6 +241,8 @@ def test_get_commit_review_context_delegates_to_service(github_client): assert result.data.abbreviated_sha == "343e2e9" github_client._pr_service.get_commit_review_context.assert_called_once_with( "343e2e9", + repo_owner=None, + repo_name=None, max_files=2, max_patch_chars=1200, ) diff --git a/plugins/titan-plugin-github/titan_plugin_github/clients/github_client.py b/plugins/titan-plugin-github/titan_plugin_github/clients/github_client.py index b915e152..4bfb03ad 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/clients/github_client.py +++ b/plugins/titan-plugin-github/titan_plugin_github/clients/github_client.py @@ -196,12 +196,16 @@ def get_commit_review_context( self, commit_ref: str, *, + repo_owner: Optional[str] = None, + repo_name: Optional[str] = None, max_files: int = 3, max_patch_chars: int = 4000, ) -> ClientResult[ReferencedCommitContext]: """Get a compact remote context for a referenced commit.""" return self._pr_service.get_commit_review_context( commit_ref, + repo_owner=repo_owner, + repo_name=repo_name, max_files=max_files, max_patch_chars=max_patch_chars, ) diff --git a/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py b/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py index f6e6b40f..3e4c793e 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py +++ b/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py @@ -57,7 +57,7 @@ def get_pull_request(self, pr_number: int) -> ClientResult[UIPullRequest]: "changedFiles", "mergeable", "isDraft", "createdAt", "updatedAt", "mergedAt", "reviews", "labels", "statusCheckRollup", "reviewDecision", - "isCrossRepository", "headRepositoryOwner", + "isCrossRepository", "headRepositoryOwner", "headRepository", ] # Fetch from network @@ -690,13 +690,25 @@ def get_commit_review_context( self, commit_ref: str, *, + repo_owner: Optional[str] = None, + repo_name: Optional[str] = None, max_files: int = 3, max_patch_chars: int = 4000, ) -> ClientResult[ReferencedCommitContext]: - """Get a compact review context for a referenced commit.""" + """Get a compact review context for a referenced commit. + + Defaults to the configured base repo. Pass `repo_owner`/`repo_name` + (e.g. a PR's head repository) to resolve commits that only exist on + a fork, such as SHAs mentioned in review replies on cross-repo PRs. + """ + repo_string = ( + f"{repo_owner}/{repo_name}" + if repo_owner and repo_name + else self.gh.get_repo_string() + ) try: output = self.gh.run_command( - ["api", f"repos/{self.gh.get_repo_string()}/commits/{commit_ref}"] + ["api", f"repos/{repo_string}/commits/{commit_ref}"] ) data = json.loads(output) diff --git a/plugins/titan-plugin-github/titan_plugin_github/models/mappers/pr_mapper.py b/plugins/titan-plugin-github/titan_plugin_github/models/mappers/pr_mapper.py index d2a76a89..f52f3549 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/models/mappers/pr_mapper.py +++ b/plugins/titan-plugin-github/titan_plugin_github/models/mappers/pr_mapper.py @@ -57,6 +57,7 @@ def from_rest_pr(rest_pr: NetworkPullRequest) -> UIPullRequest: formatted_updated_at=format_date(rest_pr.updatedAt) if rest_pr.updatedAt else "", is_cross_repository=rest_pr.isCrossRepository, head_repository_owner=rest_pr.headRepositoryOwnerLogin, + head_repository_name=rest_pr.headRepositoryName, ) diff --git a/plugins/titan-plugin-github/titan_plugin_github/models/network/rest/pull_request.py b/plugins/titan-plugin-github/titan_plugin_github/models/network/rest/pull_request.py index 17d019aa..8d18a582 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/models/network/rest/pull_request.py +++ b/plugins/titan-plugin-github/titan_plugin_github/models/network/rest/pull_request.py @@ -70,6 +70,7 @@ class NetworkPullRequest: reviewDecision: Optional[PRReviewDecision] = None isCrossRepository: bool = False headRepositoryOwnerLogin: Optional[str] = None + headRepositoryName: Optional[str] = None @classmethod def from_json(cls, data: Dict[str, Any]) -> 'NetworkPullRequest': @@ -126,6 +127,7 @@ def from_json(cls, data: Dict[str, Any]) -> 'NetworkPullRequest': ), isCrossRepository=data.get("isCrossRepository", False), headRepositoryOwnerLogin=(data.get("headRepositoryOwner") or {}).get("login"), + headRepositoryName=(data.get("headRepository") or {}).get("name"), ) diff --git a/plugins/titan-plugin-github/titan_plugin_github/models/view.py b/plugins/titan-plugin-github/titan_plugin_github/models/view.py index 119eb924..7610471a 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/models/view.py +++ b/plugins/titan-plugin-github/titan_plugin_github/models/view.py @@ -159,6 +159,7 @@ class UIPullRequest: review_status_summary: str = "" is_cross_repository: bool = False head_repository_owner: Optional[str] = None + head_repository_name: Optional[str] = None @dataclass diff --git a/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py b/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py index a0a5ce9c..0d56b679 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py +++ b/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py @@ -145,11 +145,22 @@ def _extract_referenced_commit_shas(reply_bodies: list[str]) -> list[str]: def _load_referenced_commit_contexts( ctx: WorkflowContext, threads: list[UICommentThread], + pr: Optional[UIPullRequest] = None, ) -> dict[str, list[ReferencedCommitContext]]: - """Fetch compact remote commit context for SHA references in review-thread replies.""" + """Fetch compact remote commit context for SHA references in review-thread replies. + + For cross-repo (fork) PRs, referenced SHAs may only exist on the fork's + head repository, so lookups are resolved against it instead of the base repo. + """ if not ctx.github: return {} + repo_owner: Optional[str] = None + repo_name: Optional[str] = None + if pr and pr.is_cross_repository and pr.head_repository_owner and pr.head_repository_name: + repo_owner = pr.head_repository_owner + repo_name = pr.head_repository_name + commit_cache: dict[str, ReferencedCommitContext | None] = {} contexts_by_thread: dict[str, list[ReferencedCommitContext]] = {} @@ -164,6 +175,8 @@ def _load_referenced_commit_contexts( if sha not in commit_cache: result = ctx.github.get_commit_review_context( sha, + repo_owner=repo_owner, + repo_name=repo_name, max_files=_MAX_REFERENCED_COMMIT_FILES, max_patch_chars=_MAX_REFERENCED_COMMIT_PATCH_CHARS, ) @@ -2653,7 +2666,8 @@ def build_thread_review_contexts(ctx: WorkflowContext) -> WorkflowResult: contexts = build_thread_review_contexts_operation(candidates, threads, diff) candidate_ids = {candidate.thread_id for candidate in candidates} candidate_threads = [thread for thread in threads if thread.thread_id in candidate_ids] - commit_contexts_by_thread = _load_referenced_commit_contexts(ctx, candidate_threads) + review_pr = ctx.get("review_pr") + commit_contexts_by_thread = _load_referenced_commit_contexts(ctx, candidate_threads, review_pr) if commit_contexts_by_thread: contexts = [ context.model_copy( From a02cd3e4533f5151f69dc5ee28acf82bf09e24f3 Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 8 Jul 2026 12:02:55 +0200 Subject: [PATCH 06/12] Fix: thread_resolution_operations.py By: finxo --- .../operations/thread_resolution_operations.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py b/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py index 6c790168..a66d394f 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py +++ b/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py @@ -258,10 +258,9 @@ def _threads_to_text(contexts: list[ThreadReviewContext]) -> str: parts.append(" Files: " + ", ".join(commit.changed_files)) if commit.patch_excerpt: parts.append(" Patch excerpt:") - parts.append(" ```diff") - for line in commit.patch_excerpt.splitlines(): - parts.append(f" {line}") - parts.append(" ```") + parts.append("```diff") + parts.extend(commit.patch_excerpt.splitlines()) + parts.append("```") parts.append("") From 26c675165303fe241e00fc0b4a93c2e82db4e1cc Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 8 Jul 2026 12:05:36 +0200 Subject: [PATCH 07/12] Fix: code_review_steps.py By: finxo --- .../titan_plugin_github/steps/code_review_steps.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py b/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py index 0d56b679..c551ec60 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py +++ b/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py @@ -863,9 +863,7 @@ def fetch_pr_review_bundle(ctx: WorkflowContext) -> WorkflowResult: case ClientSuccess(data=current_user): review_current_user = current_user case ClientError(error_message=err): - ctx.textual.error_text(f"Failed to get current user: {err}") - ctx.textual.end_step("error") - return Error(f"Failed to get current user: {err}") + ctx.textual.warning_text(f"Could not get current user: {err}") ctx.textual.dim_text( f"{len(changed_file_paths)} files · {formatted_summary} · " From 86205a1f9fc70ca83556167ea69baa82f5a19a0e Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 8 Jul 2026 13:45:47 +0200 Subject: [PATCH 08/12] Fix: pr_service.py By: finxo --- .../clients/services/pr_service.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py b/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py index 3e4c793e..c4509c6a 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py +++ b/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py @@ -733,8 +733,19 @@ def get_commit_review_context( continue status = str(file_data.get("status", "modified")).strip() + previous_filename = str(file_data.get("previous_filename") or "").strip() + + if status == "added": + old_path, new_path = "/dev/null", filename + elif status == "removed": + old_path, new_path = filename, "/dev/null" + elif status == "renamed" and previous_filename: + old_path, new_path = previous_filename, filename + else: + old_path, new_path = filename, filename + patch_sections.append( - f"diff --git a/{filename} b/{filename}\n" + f"diff --git a/{old_path} b/{new_path}\n" f"# status: {status}\n" f"{patch.strip()}" ) From e7b0a7226ede86f7ebdcd5a4cccb51c6db322a47 Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 8 Jul 2026 14:03:39 +0200 Subject: [PATCH 09/12] Fix: thread_resolution_operations.py By: finxo --- .../operations/thread_resolution_operations.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py b/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py index a66d394f..4423a3a9 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py +++ b/plugins/titan-plugin-github/titan_plugin_github/operations/thread_resolution_operations.py @@ -253,7 +253,11 @@ def _threads_to_text(contexts: list[ThreadReviewContext]) -> str: for commit in ctx.referenced_commits: parts.append(f"- Commit `{commit.abbreviated_sha}`") if commit.message: - parts.append(f" Message: {commit.message}") + parts.append(" Message:") + parts.append(" ```") + for line in commit.message.splitlines(): + parts.append(f" {line}") + parts.append(" ```") if commit.changed_files: parts.append(" Files: " + ", ".join(commit.changed_files)) if commit.patch_excerpt: From 626169b15971a46ba2f4e3b59e5a99e4b4a99a21 Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 8 Jul 2026 14:07:00 +0200 Subject: [PATCH 10/12] Fix: code_review_steps.py By: finxo --- .../titan_plugin_github/steps/code_review_steps.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py b/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py index c551ec60..e706b2ea 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py +++ b/plugins/titan-plugin-github/titan_plugin_github/steps/code_review_steps.py @@ -147,7 +147,11 @@ def _load_referenced_commit_contexts( threads: list[UICommentThread], pr: Optional[UIPullRequest] = None, ) -> dict[str, list[ReferencedCommitContext]]: - """Fetch compact remote commit context for SHA references in review-thread replies. + """Fetch compact remote commit context for SHA references in the PR author's replies. + + Only replies authored by the PR author are scanned for SHAs, since the AI + is deciding whether the author's response resolved the review comment; SHAs + mentioned by other reviewers or bots aren't claims made by the author. For cross-repo (fork) PRs, referenced SHAs may only exist on the fork's head repository, so lookups are resolved against it instead of the base repo. @@ -161,11 +165,16 @@ def _load_referenced_commit_contexts( repo_owner = pr.head_repository_owner repo_name = pr.head_repository_name + pr_author = pr.author_name if pr else None + commit_cache: dict[str, ReferencedCommitContext | None] = {} contexts_by_thread: dict[str, list[ReferencedCommitContext]] = {} for thread in threads: - reply_bodies = [reply.body for reply in thread.replies] + reply_bodies = [ + reply.body for reply in thread.replies + if pr_author is None or reply.author_login == pr_author + ] referenced_shas = _extract_referenced_commit_shas(reply_bodies) if not referenced_shas: continue From 2664a81214cba9f54f687e924ce3d9258eee7cac Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 8 Jul 2026 14:08:24 +0200 Subject: [PATCH 11/12] Fix: pr_service.py By: finxo --- .../titan_plugin_github/clients/services/pr_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py b/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py index c4509c6a..54121209 100644 --- a/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py +++ b/plugins/titan-plugin-github/titan_plugin_github/clients/services/pr_service.py @@ -778,4 +778,5 @@ def _truncate_text(text: str, max_chars: int) -> str: """Trim large gh payload sections without hiding that they were truncated.""" if len(text) <= max_chars: return text - return text[: max_chars - 15].rstrip() + "\n... [truncated]" + suffix = "\n... [truncated]" + return text[: max_chars - len(suffix)].rstrip() + suffix From 4c32b76982a23e0c129603696460bda577351cf3 Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 8 Jul 2026 14:10:51 +0200 Subject: [PATCH 12/12] Fix: test_code_review_steps.py By: finxo --- .../tests/unit/test_code_review_steps.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py b/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py index b1b2a2f5..c05cc208 100644 --- a/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py +++ b/plugins/titan-plugin-github/tests/unit/test_code_review_steps.py @@ -448,5 +448,12 @@ def test_build_thread_review_contexts_ignores_unavailable_referenced_commits(): result = build_thread_review_contexts(ctx) assert isinstance(result, Success) + ctx.github.get_commit_review_context.assert_called_once_with( + "deadbee", + repo_owner=None, + repo_name=None, + max_files=3, + max_patch_chars=4000, + ) contexts = ctx.data["thread_review_contexts"] assert contexts[0].referenced_commits == []