Skip to content

Commit 4d88c01

Browse files
authored
feat: Add general PR comments support and refactor review creation (#179)
1 parent 45ba2b2 commit 4d88c01

File tree

10 files changed

+477
-128
lines changed

10 files changed

+477
-128
lines changed

plugins/titan-plugin-github/tests/services/test_review_service.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,3 +251,51 @@ def test_resolve_review_thread_api_error(review_service, mock_graphql_network):
251251

252252
assert isinstance(result, ClientError)
253253
assert result.error_code == "API_ERROR"
254+
255+
256+
def test_create_draft_review_success(review_service, mock_gh_network):
257+
"""Test successful draft review creation via GHNetwork (not raw subprocess)."""
258+
payload = {
259+
"commit_id": "abc123",
260+
"body": "Review body",
261+
"event": "COMMENT",
262+
"comments": []
263+
}
264+
mock_gh_network.run_command.return_value = json.dumps({"id": 42})
265+
mock_gh_network.get_repo_string.return_value = "owner/repo"
266+
267+
result = review_service.create_draft_review(pr_number=1, payload=payload)
268+
269+
assert isinstance(result, ClientSuccess)
270+
assert result.data == 42
271+
272+
# Verify it went through GHNetwork, not raw subprocess
273+
call_args = mock_gh_network.run_command.call_args
274+
args = call_args[0][0]
275+
assert "--method" in args
276+
assert "POST" in args
277+
assert "/pulls/1/reviews" in " ".join(args)
278+
# Payload passed as stdin_input, not via subprocess directly
279+
assert call_args[1].get("stdin_input") or call_args[0][1]
280+
281+
282+
def test_create_draft_review_parse_error(review_service, mock_gh_network):
283+
"""Test handling invalid JSON response from gh CLI."""
284+
mock_gh_network.run_command.return_value = "not-valid-json"
285+
mock_gh_network.get_repo_string.return_value = "owner/repo"
286+
287+
result = review_service.create_draft_review(pr_number=1, payload={})
288+
289+
assert isinstance(result, ClientError)
290+
assert result.error_code == "PARSE_ERROR"
291+
292+
293+
def test_create_draft_review_api_error(review_service, mock_gh_network):
294+
"""Test handling gh CLI failure during draft review creation."""
295+
mock_gh_network.run_command.side_effect = GitHubAPIError("Unauthorized")
296+
mock_gh_network.get_repo_string.return_value = "owner/repo"
297+
298+
result = review_service.create_draft_review(pr_number=1, payload={})
299+
300+
assert isinstance(result, ClientError)
301+
assert result.error_code == "API_ERROR"

plugins/titan-plugin-github/tests/unit/test_comment_operations.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from titan_cli.core.result import ClientSuccess, ClientError
1010
from titan_plugin_github.operations.comment_operations import (
1111
build_ai_review_context,
12+
build_ai_review_prompt,
1213
find_ai_response_file,
1314
create_commit_message,
1415
reply_to_comment_batch,
@@ -61,6 +62,40 @@ def test_handles_missing_diff_hunk(self, sample_ui_comment_thread):
6162

6263

6364

65+
@pytest.mark.unit
66+
class TestBuildAIReviewPrompt:
67+
"""Test AI review prompt building"""
68+
69+
def test_includes_response_file_path(self):
70+
"""Test that response_file path appears in the prompt"""
71+
prompt = build_ai_review_prompt("/tmp/titan-ai-response-comment-123.txt")
72+
73+
assert "/tmp/titan-ai-response-comment-123.txt" in prompt
74+
75+
def test_preserves_context_placeholder(self):
76+
"""Test that {context} placeholder is preserved for later substitution"""
77+
prompt = build_ai_review_prompt("/tmp/response.txt")
78+
79+
assert "{context}" in prompt
80+
81+
def test_different_paths_produce_different_prompts(self):
82+
"""Test that different response file paths produce different prompts"""
83+
prompt_a = build_ai_review_prompt("/tmp/response-111.txt")
84+
prompt_b = build_ai_review_prompt("/tmp/response-222.txt")
85+
86+
assert prompt_a != prompt_b
87+
assert "/tmp/response-111.txt" in prompt_a
88+
assert "/tmp/response-222.txt" in prompt_b
89+
90+
def test_prompt_contains_key_instructions(self):
91+
"""Test that the prompt includes the core task instructions"""
92+
prompt = build_ai_review_prompt("/tmp/response.txt")
93+
94+
assert "Your Task" in prompt
95+
assert "code changes" in prompt
96+
assert "Ctrl+C" in prompt
97+
98+
6499
@pytest.mark.unit
65100
class TestFindAIResponseFile:
66101
"""Test AI response file finding"""

plugins/titan-plugin-github/titan_plugin_github/clients/github_client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,10 @@ def reply_to_comment(
215215
"""Reply to a PR comment."""
216216
return self._review_service.reply_to_comment(pr_number, comment_id, body)
217217

218+
def get_pr_general_comments(self, pr_number: int) -> ClientResult[List[UICommentThread]]:
219+
"""Get general PR comments (not attached to code lines)."""
220+
return self._review_service.get_pr_general_comments(pr_number)
221+
218222
def add_issue_comment(self, pr_number: int, body: str) -> ClientResult[None]:
219223
"""Add a general comment to PR (issue comment)."""
220224
return self._review_service.add_issue_comment(pr_number, body)

plugins/titan-plugin-github/titan_plugin_github/clients/network/graphql_queries.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,29 @@
4444
}
4545
'''
4646

47+
GET_PR_ISSUE_COMMENTS = '''
48+
query($owner: String!, $repo: String!, $prNumber: Int!) {
49+
repository(owner: $owner, name: $repo) {
50+
pullRequest(number: $prNumber) {
51+
comments(first: 100) {
52+
nodes {
53+
databaseId
54+
body
55+
author {
56+
login
57+
... on User {
58+
name
59+
}
60+
}
61+
createdAt
62+
updatedAt
63+
}
64+
}
65+
}
66+
}
67+
}
68+
'''
69+
4770
GET_PR_NODE_ID = '''
4871
query($owner: String!, $repo: String!, $prNumber: Int!) {
4972
repository(owner: $owner, name: $repo) {

plugins/titan-plugin-github/titan_plugin_github/clients/services/review_service.py

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@
66
Uses GraphQL for complex operations (threads, comments, resolve).
77
"""
88
import json
9-
import subprocess
109
from typing import List, Optional, Dict, Any
1110

1211
from titan_cli.core.result import ClientResult, ClientSuccess, ClientError
1312
from titan_cli.core.logging import log_client_operation
1413
from ..network import GHNetwork, GraphQLNetwork, graphql_queries
1514
from ...models.network.rest import NetworkReview
16-
from ...models.network.graphql import GraphQLPullRequestReviewThread
15+
from ...models.network.graphql import GraphQLPullRequestReviewThread, GraphQLIssueComment
1716
from ...models.view import UICommentThread, UIReview
1817
from ...models.mappers import from_graphql_review_thread, from_network_review
1918
from ...exceptions import GitHubAPIError
@@ -106,6 +105,66 @@ def get_pr_review_threads(
106105
except GitHubAPIError as e:
107106
return ClientError(error_message=str(e), error_code="API_ERROR")
108107

108+
@log_client_operation()
109+
def get_pr_general_comments(
110+
self, pr_number: int
111+
) -> ClientResult[List[UICommentThread]]:
112+
"""
113+
Get general PR comments (not attached to code lines).
114+
115+
Uses GraphQL to fetch top-level PR comments and wraps each one as
116+
a pseudo-thread (thread_id = "general_{id}") for uniform rendering.
117+
118+
Args:
119+
pr_number: PR number
120+
121+
Returns:
122+
ClientResult[List[UICommentThread]]
123+
"""
124+
try:
125+
repo_string = self.gh.get_repo_string()
126+
owner, repo = repo_string.split('/')
127+
128+
variables = {
129+
"owner": owner,
130+
"repo": repo,
131+
"prNumber": pr_number
132+
}
133+
134+
response = self.graphql.run_query(
135+
graphql_queries.GET_PR_ISSUE_COMMENTS,
136+
variables
137+
)
138+
139+
comments_data = (
140+
response.get("data", {})
141+
.get("repository", {})
142+
.get("pullRequest", {})
143+
.get("comments", {})
144+
.get("nodes", [])
145+
)
146+
147+
network_comments = [
148+
GraphQLIssueComment.from_graphql(c) for c in comments_data
149+
]
150+
151+
ui_threads = [
152+
UICommentThread.from_issue_comment(c) for c in network_comments
153+
]
154+
155+
return ClientSuccess(
156+
data=ui_threads,
157+
message=f"Found {len(ui_threads)} general comments"
158+
)
159+
160+
except (KeyError, ValueError) as e:
161+
return ClientError(
162+
error_message=f"Failed to parse general comments: {e}",
163+
error_code="PARSE_ERROR"
164+
)
165+
except GitHubAPIError as e:
166+
return ClientError(error_message=str(e), error_code="API_ERROR")
167+
109168
@log_client_operation()
110169
def resolve_review_thread(self, thread_node_id: str) -> ClientResult[None]:
111170
"""
@@ -191,16 +250,9 @@ def create_draft_review(
191250
"--input", "-",
192251
]
193252

194-
# Run with JSON payload via stdin
195-
result = subprocess.run(
196-
["gh"] + args,
197-
input=json.dumps(payload),
198-
capture_output=True,
199-
text=True,
200-
check=True,
201-
)
253+
result = self.gh.run_command(args, stdin_input=json.dumps(payload))
202254

203-
response = json.loads(result.stdout)
255+
response = json.loads(result)
204256
review_id = response["id"]
205257

206258
return ClientSuccess(
@@ -213,11 +265,6 @@ def create_draft_review(
213265
error_message=f"Failed to parse review response: {e}",
214266
error_code="PARSE_ERROR"
215267
)
216-
except subprocess.CalledProcessError as e:
217-
return ClientError(
218-
error_message=f"Failed to create draft review: gh API returned exit code {e.returncode}",
219-
error_code="API_ERROR"
220-
)
221268
except GitHubAPIError as e:
222269
return ClientError(error_message=str(e), error_code="API_ERROR")
223270

plugins/titan-plugin-github/titan_plugin_github/models/view.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,36 @@ def from_review_thread(cls, thread: 'Any') -> 'UICommentThread':
9494
from .mappers import from_graphql_review_thread
9595
return from_graphql_review_thread(thread)
9696

97+
@classmethod
98+
def from_issue_comment(cls, comment: 'Any') -> 'UICommentThread':
99+
"""
100+
Wrap a GraphQLIssueComment as a pseudo-thread for uniform rendering.
101+
102+
General PR comments have no thread concept, so they are wrapped in a
103+
UICommentThread with a synthetic thread_id ("general_{id}") that
104+
downstream code uses to distinguish them from review threads.
105+
106+
Args:
107+
comment: GraphQLIssueComment instance from GraphQL
108+
109+
Returns:
110+
UICommentThread with thread_id="general_{id}", no replies, not resolved
111+
"""
112+
from .mappers import from_graphql_issue_comment
113+
ui_comment = from_graphql_issue_comment(comment)
114+
return cls(
115+
thread_id=f"general_{comment.databaseId}",
116+
main_comment=ui_comment,
117+
replies=[],
118+
is_resolved=False,
119+
is_outdated=False
120+
)
121+
122+
@property
123+
def is_general_comment(self) -> bool:
124+
"""True if this thread wraps a general PR comment (not an inline review thread)."""
125+
return self.thread_id.startswith("general_")
126+
97127

98128
@dataclass
99129
class UIPullRequest:

plugins/titan-plugin-github/titan_plugin_github/operations/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from .comment_operations import (
1616
build_ai_review_context,
17+
build_ai_review_prompt,
1718
find_ai_response_file,
1819
create_commit_message,
1920
reply_to_comment_batch,
@@ -22,6 +23,7 @@
2223

2324
from .pr_operations import (
2425
fetch_pr_threads,
26+
fetch_pr_general_comments,
2527
)
2628

2729
from .worktree_operations import (
@@ -44,13 +46,15 @@
4446
__all__ = [
4547
# Comment operations
4648
"build_ai_review_context",
49+
"build_ai_review_prompt",
4750
"find_ai_response_file",
4851
"create_commit_message",
4952
"reply_to_comment_batch",
5053
"prepare_replies_for_sending",
5154

5255
# PR operations
5356
"fetch_pr_threads",
57+
"fetch_pr_general_comments",
5458

5559
# Worktree operations
5660
"setup_worktree",

0 commit comments

Comments
 (0)