Skip to content

Commit 95c9657

Browse files
committed
Address non-inline PR comments in the watcher loop
Previously the PR lifecycle only fetched inline review comments (pulls/{n}/comments). General issue-level PR comments and the free-text body of review submissions were either ignored or stored but never surfaced to Claude, so whole classes of reviewer feedback went unanswered. The pr_comments table now carries a `kind` column (inline | issue | review_summary) with a composite unique index on (kind, github_comment_id) since those IDs come from different server-side tables and can collide. Migration 22 also stamps a pr_comments_rollout_cutoff timestamp so existing active PRs don't get retroactively reworked — any source comment older than the cutoff is inserted as already-addressed. The service now pulls all three sources in each tick: - listPrComments → kind=inline (existing behavior) - listIssueComments → kind=issue (new) - synthesized per review with non-empty body → kind=review_summary Bot-authored issue-comment replies carry a hidden `<!-- sustn:task=... -->` marker so they're filtered out on the next fetch without needing to resolve the gh user's login (robust against auth swaps). Each item sent to Claude is tagged [KIND: ...] in addition to the existing COMMENT_ID tag; the reply JSON requires `kind` echoed back so we can route inline replies to the pull-comment replies endpoint and issue / review-summary replies to a new top-level issue comment. The resolved-threads filter is now scoped to kind=inline to avoid dropping issue rows whose ID coincidentally equals a resolved inline thread's root ID.
1 parent 729cf73 commit 95c9657

7 files changed

Lines changed: 319 additions & 52 deletions

File tree

src-tauri/src/engine_commands.rs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -855,42 +855,48 @@ pub async fn engine_address_review(
855855
let prompt = format!(
856856
r#"IMPORTANT: You are running as an automated background agent in non-interactive mode. Commit your changes directly — do NOT ask for permission.
857857
858-
A human reviewer has left comments on a PR. You need to handle EVERY comment — either by making code changes or by drafting a reply.
858+
A human reviewer has left feedback on a PR. You need to handle EVERY item — either by making code changes or by drafting a reply.
859859
860860
## PR Description
861861
{pr_description}
862862
{pr_context_section}
863-
## Review Comments
864-
Each comment below has a COMMENT_ID number that you MUST include in your response.
863+
## Review Items
864+
Each item below has a COMMENT_ID and a KIND tag that you MUST echo back in your response.
865+
866+
KIND values and what they mean:
867+
- `inline` — a review comment anchored to a specific diff line. Usually narrow and code-specific.
868+
- `issue` — a general PR comment not tied to a line. May be a question, a request, or plain chat. Not all of them need a code change — sometimes a plain reply is correct.
869+
- `review_summary` — the free-text body a reviewer wrote when submitting a review. Often contains overall asks (e.g. "please split this into two PRs", "looks good but rename X") that complement the per-line comments in the same review. Treat these as first-class feedback.
865870
866871
{review_comments}
867872
{prefs_section}
868873
869874
## Instructions
870-
For EACH review comment above:
875+
For EACH item above:
871876
872-
1. **If it requires code changes** (bug fix, refactor, improvement, the reviewer is questioning an approach and they're right): make the changes, commit with trailer SUSTN-Task: {task_id}, and draft a reply explaining what you changed.
877+
1. **If it requires code changes** (bug fix, refactor, improvement, a legitimate concern about the approach): make the changes, commit with trailer SUSTN-Task: {task_id}, and draft a reply explaining what you changed.
873878
874879
2. **If it's a question about your reasoning** (why did you do X?): explain your reasoning clearly — you have context from when you wrote this code.
875880
876-
3. **If it's praise or acknowledgment** (looks good, nice, etc.): draft a brief thanks.
881+
3. **If it's conversational** (praise, a check-in, a "what do you think about Y?"): draft a brief, appropriate reply. No code change needed.
877882
878-
CRITICAL: You MUST return a reply for EVERY comment. Use the exact COMMENT_ID number from each comment header above.
883+
CRITICAL: You MUST return a reply for EVERY item. Use the exact COMMENT_ID number and KIND value from the header above each item.
879884
880885
After making any code changes and committing, output ONLY this JSON (no markdown):
881886
{{
882887
"replies": [
883888
{{
884889
"comment_id": 1234567890,
885-
"reply": "Your response to this specific comment",
890+
"kind": "inline",
891+
"reply": "Your response to this specific item",
886892
"made_code_changes": true
887893
}}
888894
],
889895
"summary": "Brief description of what was changed",
890896
"files_modified": ["list", "of", "files"]
891897
}}
892898
893-
The comment_id MUST be the numeric ID from the [COMMENT_ID: <number>] tag in each comment above. Do NOT use null."#
899+
The comment_id MUST be the numeric ID from the [COMMENT_ID: <number>] tag. The kind MUST be one of `inline`, `issue`, or `review_summary` matching the [KIND: ...] tag. Do NOT use null for either field."#
894900
);
895901

896902
// Create/reuse worktree for task isolation

src-tauri/src/migrations.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,5 +411,22 @@ pub fn migrations() -> Vec<Migration> {
411411
"#,
412412
kind: MigrationKind::Up,
413413
},
414+
// Migration 22: track comment source kind so issue-level and
415+
// review-summary comments live alongside inline review comments
416+
// without colliding on github_comment_id (different server-side
417+
// ID namespaces can reuse the same integer).
418+
Migration {
419+
version: 22,
420+
description: "add kind to pr_comments and seed rollout cutoff",
421+
sql: r#"
422+
ALTER TABLE pr_comments ADD COLUMN kind TEXT NOT NULL DEFAULT 'inline';
423+
DROP INDEX IF EXISTS idx_pr_comments_github_id;
424+
CREATE UNIQUE INDEX IF NOT EXISTS idx_pr_comments_kind_github_id
425+
ON pr_comments(kind, github_comment_id);
426+
INSERT OR IGNORE INTO global_settings (key, value) VALUES
427+
('pr_comments_rollout_cutoff', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
428+
"#,
429+
kind: MigrationKind::Up,
430+
},
414431
]
415432
}

src/core/db/pr-lifecycle.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import Database from "@tauri-apps/plugin-sql";
77
import { invoke } from "@tauri-apps/api/core";
88
import { config } from "@core/config";
9-
import type { PrReview, PrComment } from "@core/types/task";
9+
import type { PrReview, PrComment, PrCommentKind } from "@core/types/task";
1010

1111
async function getDb() {
1212
return await Database.load(config.dbUrl);
@@ -114,6 +114,7 @@ interface PrCommentRow {
114114
id: string;
115115
task_id: string;
116116
github_comment_id: number;
117+
kind: string;
117118
in_reply_to_id: number | null;
118119
reviewer: string;
119120
body: string;
@@ -133,6 +134,7 @@ function rowToComment(row: PrCommentRow): PrComment {
133134
id: row.id,
134135
taskId: row.task_id,
135136
githubCommentId: row.github_comment_id,
137+
kind: (row.kind as PrCommentKind) ?? "inline",
136138
inReplyToId: row.in_reply_to_id ?? undefined,
137139
reviewer: row.reviewer,
138140
body: row.body,
@@ -153,53 +155,64 @@ export async function upsertComment(
153155
taskId: string,
154156
comment: {
155157
githubCommentId: number;
158+
kind: PrCommentKind;
156159
inReplyToId?: number;
157160
reviewer: string;
158161
body: string;
159162
path?: string;
160163
line?: number;
161164
side?: string;
162165
commitId?: string;
166+
/**
167+
* When the source comment predates the non-inline-comment rollout,
168+
* the service passes a commit-sha-like sentinel so the row is
169+
* inserted as already-addressed and never re-sent to Claude.
170+
*/
171+
preAddressedSentinel?: string;
163172
},
164173
): Promise<PrComment> {
165174
const db = await getDb();
166175

167-
// Check if already exists — update body if so
176+
// Check if already exists — match on (kind, github_comment_id) because
177+
// review / pull-comment / issue-comment IDs share an integer space but
178+
// come from different server-side tables and can collide.
168179
const existing = await db.select<PrCommentRow[]>(
169-
"SELECT * FROM pr_comments WHERE github_comment_id = $1",
170-
[comment.githubCommentId],
180+
"SELECT * FROM pr_comments WHERE kind = $1 AND github_comment_id = $2",
181+
[comment.kind, comment.githubCommentId],
171182
);
172183

173184
if (existing.length > 0) {
174-
// Update body in case it changed
175185
if (existing[0].body !== comment.body) {
176186
await db.execute(
177-
"UPDATE pr_comments SET body = $1, updated_at = CURRENT_TIMESTAMP WHERE github_comment_id = $2",
178-
[comment.body, comment.githubCommentId],
187+
"UPDATE pr_comments SET body = $1, updated_at = CURRENT_TIMESTAMP WHERE kind = $2 AND github_comment_id = $3",
188+
[comment.body, comment.kind, comment.githubCommentId],
179189
);
180190
}
181191
const rows = await db.select<PrCommentRow[]>(
182-
"SELECT * FROM pr_comments WHERE github_comment_id = $1",
183-
[comment.githubCommentId],
192+
"SELECT * FROM pr_comments WHERE kind = $1 AND github_comment_id = $2",
193+
[comment.kind, comment.githubCommentId],
184194
);
185195
return rowToComment(rows[0]);
186196
}
187197

188198
const id = await invoke<string>("generate_task_id");
189199
await db.execute(
190-
`INSERT INTO pr_comments (id, task_id, github_comment_id, in_reply_to_id, reviewer, body, path, line, side, commit_id)
191-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
200+
`INSERT INTO pr_comments (id, task_id, github_comment_id, kind, in_reply_to_id, reviewer, body, path, line, side, commit_id, addressed_in_commit, classification)
201+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
192202
[
193203
id,
194204
taskId,
195205
comment.githubCommentId,
206+
comment.kind,
196207
comment.inReplyToId ?? null,
197208
comment.reviewer,
198209
comment.body,
199210
comment.path ?? null,
200211
comment.line ?? null,
201212
comment.side ?? null,
202213
comment.commitId ?? null,
214+
comment.preAddressedSentinel ?? null,
215+
comment.preAddressedSentinel ? "resolved" : null,
203216
],
204217
);
205218

@@ -245,27 +258,44 @@ export async function updateCommentClassification(
245258
}
246259

247260
export async function markCommentAddressed(
261+
kind: PrCommentKind,
248262
githubCommentId: number,
249263
commitSha: string,
250264
): Promise<void> {
251265
const db = await getDb();
252266
await db.execute(
253-
"UPDATE pr_comments SET addressed_in_commit = $1, classification = 'resolved', updated_at = CURRENT_TIMESTAMP WHERE github_comment_id = $2",
254-
[commitSha, githubCommentId],
267+
"UPDATE pr_comments SET addressed_in_commit = $1, classification = 'resolved', updated_at = CURRENT_TIMESTAMP WHERE kind = $2 AND github_comment_id = $3",
268+
[commitSha, kind, githubCommentId],
255269
);
256270
}
257271

258272
export async function setCommentReply(
273+
kind: PrCommentKind,
259274
githubCommentId: number,
260275
reply: string,
261276
): Promise<void> {
262277
const db = await getDb();
263278
await db.execute(
264-
"UPDATE pr_comments SET our_reply = $1, updated_at = CURRENT_TIMESTAMP WHERE github_comment_id = $2",
265-
[reply, githubCommentId],
279+
"UPDATE pr_comments SET our_reply = $1, updated_at = CURRENT_TIMESTAMP WHERE kind = $2 AND github_comment_id = $3",
280+
[reply, kind, githubCommentId],
266281
);
267282
}
268283

284+
/**
285+
* Read the rollout cutoff timestamp stamped at migration 22 install time.
286+
* Comments/reviews with source timestamp <= cutoff are treated as
287+
* pre-existing history and not fed back to Claude.
288+
*/
289+
export async function getPrCommentsRolloutCutoff(): Promise<
290+
string | undefined
291+
> {
292+
const db = await getDb();
293+
const rows = await db.select<{ value: string }[]>(
294+
"SELECT value FROM global_settings WHERE key = 'pr_comments_rollout_cutoff'",
295+
);
296+
return rows[0]?.value;
297+
}
298+
269299
/** Get all tasks that have an active PR lifecycle (for polling) */
270300
export async function getTasksWithActivePr(): Promise<
271301
{

src/core/services/github.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ export interface GhPrComment {
3030
updated_at: string;
3131
}
3232

33+
/** Issue-level comment on a PR (general discussion, not tied to a line). */
34+
export interface GhIssueComment {
35+
id: number;
36+
user: { login: string };
37+
body: string;
38+
created_at: string;
39+
updated_at: string;
40+
}
41+
3342
export interface GhPrStatus {
3443
state: "open" | "closed" | "merged";
3544
merged: boolean;
@@ -185,6 +194,57 @@ export async function postPrComment(
185194
);
186195
}
187196

197+
// ── Issue-level Comments ────────────────────────────────────
198+
199+
/**
200+
* List issue-level comments on a PR (general conversation, not tied to a
201+
* diff line). These come from the issues endpoint because PRs are issues
202+
* on the GitHub data model.
203+
*/
204+
export async function listIssueComments(
205+
repoPath: string,
206+
owner: string,
207+
repo: string,
208+
prNumber: number,
209+
): Promise<GhIssueComment[]> {
210+
return ghApi<GhIssueComment[]>(
211+
repoPath,
212+
`repos/${owner}/${repo}/issues/${prNumber}/comments`,
213+
);
214+
}
215+
216+
/**
217+
* Marker appended to every bot-authored issue comment so we can identify
218+
* and skip them on the next fetch, without having to resolve the gh
219+
* user's login. More robust than login matching if auth is swapped.
220+
*/
221+
export const SUSTN_MARKER_PREFIX = "<!-- sustn:task=";
222+
223+
export function sustnMarker(taskId: string): string {
224+
return `${SUSTN_MARKER_PREFIX}${taskId} -->`;
225+
}
226+
227+
export function bodyHasSustnMarker(body: string): boolean {
228+
return body.includes(SUSTN_MARKER_PREFIX);
229+
}
230+
231+
/**
232+
* Post an issue comment on a PR with a trailing marker identifying it as
233+
* authored by SUSTN for a given task. The marker is invisible in GitHub's
234+
* rendered markdown but lets us dedup on fetch.
235+
*/
236+
export async function postPrCommentWithMarker(
237+
repoPath: string,
238+
owner: string,
239+
repo: string,
240+
prNumber: number,
241+
taskId: string,
242+
body: string,
243+
): Promise<{ id: number }> {
244+
const stamped = `${body}\n\n${sustnMarker(taskId)}`;
245+
return postPrComment(repoPath, owner, repo, prNumber, stamped);
246+
}
247+
188248
// ── Re-request Review ───────────────────────────────────────
189249

190250
export async function requestReview(

0 commit comments

Comments
 (0)