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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion apps/server/src/orchestration-v2/pullRequestWatch.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type {
PullRequestCheck,
PullRequestComment,
PullRequestDetail,
ThreadPullRequestWatch,
} from "@t3tools/contracts";
import { assert, describe, it } from "@effect/vitest";
Expand Down Expand Up @@ -119,6 +118,27 @@ describe("evaluatePullRequestWatch", () => {
assert.deepEqual(again.next.remarkIds, [first.id, "late"]);
});

it("reports edits after the watermark once and counts them toward the wake limit", () => {
const old = remark("greptile[bot]", "2026-10-02T11:00:00Z");
const watching = watch({
headSha: "aaaaaaaaaa",
remarkIds: [old.id],
wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1,
});
assert.deepEqual(evaluatePullRequestWatch(watching, detail(), [old]).changes, []);
const edited = { ...old, editedAt: "2026-10-02T12:06:00Z" };
const report = evaluatePullRequestWatch(watching, detail(), [edited]);
assert.deepEqual(report.changes, [{ kind: "remarks", remarks: [edited] }]);
assert.equal(report.next.remarksThrough, edited.editedAt);
assert.deepEqual(report.next.remarkIds, [old.id]);
assert.isTrue(report.exhausted);
assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), [edited]).changes, []);
const late = { ...edited, id: "late" };
assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), [edited, late]).changes, [
{ kind: "remarks", remarks: [late] },
]);
});

it("does not treat a failed check read as a rerun", () => {
const failed = detail({ checks: [check("lint", "failure")] });
const reported = evaluatePullRequestWatch(watch(), failed, noRemarks);
Expand Down
10 changes: 6 additions & 4 deletions apps/server/src/orchestration-v2/pullRequestWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,20 @@ export function evaluatePullRequestWatch(

const own = (detail.viewer ?? detail.author?.login)?.toLowerCase();
const through = Date.parse(watch.remarksThrough);
// An edit counts as new activity, so bots that rewrite one summary comment still wake the agent.
const activeAt = (remark: PullRequestComment) => remark.editedAt ?? remark.createdAt;
// GitHub times are per second, so remarks at the boundary time are told apart by ID.
const fresh = (remarks ?? []).filter((remark) => {
const at = Date.parse(remark.createdAt);
const at = Date.parse(activeAt(remark));
return (
(at > through || (at === through && !watch.remarkIds.includes(remark.id))) &&
remark.author?.login.toLowerCase() !== own
);
});
if (fresh.length > 0) changes.push({ kind: "remarks", remarks: fresh });
const latest = Math.max(through, ...fresh.map((remark) => Date.parse(remark.createdAt)));
const atLatest = fresh.filter((remark) => Date.parse(remark.createdAt) === latest);
const remarksThrough = latest === through ? watch.remarksThrough : atLatest[0]!.createdAt;
const latest = Math.max(through, ...fresh.map((remark) => Date.parse(activeAt(remark))));
const atLatest = fresh.filter((remark) => Date.parse(activeAt(remark)) === latest);
const remarksThrough = latest === through ? watch.remarksThrough : activeAt(atLatest[0]!);
const remarkIds = [
...(latest === through ? watch.remarkIds : []),
...atLatest.map((remark) => remark.id),
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2338,6 +2338,7 @@ export const make = Effect.gen(function* () {
let reviewers: ReadonlyArray<PullRequestActor> = [];
let reactions: GitHubReviewThreadPage["reactions"] = [];
const reactionsById = new Map<string, ReadonlyArray<PullRequestReaction>>();
const editedAtById = new Map<string, string>();
let commits: GitHubReviewThreadPage["commits"] = [];
let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false };
const dismissalsByReviewId = new Map<string, string>();
Expand All @@ -2356,6 +2357,7 @@ export const make = Effect.gen(function* () {
reviewers = read.reviewers;
reactions = read.reactions;
for (const [id, entry] of read.reactionsById) reactionsById.set(id, entry);
for (const [id, editedAt] of read.editedAtById) editedAtById.set(id, editedAt);
commits = read.commits;
viewer = read.viewer;
for (const [id, message] of read.dismissalsByReviewId)
Expand Down Expand Up @@ -2411,6 +2413,7 @@ export const make = Effect.gen(function* () {
truncated: cursor !== null || entries.some((entry) => entry.nextCommentCursor !== null),
reactions,
reactionsById,
editedAtById,
reviewers,
avatarsByLogin,
botLogins,
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,7 @@ describe("getChangeRequest commits", () => {
truncated: false,
reactions: [],
reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(),
editedAtById: new Map<string, string>(),
reviewers: [],
avatarsByLogin: new Map<string, string>(),
botLogins: new Set<string>(),
Expand Down Expand Up @@ -969,6 +970,7 @@ describe("getChangeRequestActivity dismissed reviews", () => {
truncated: false,
reactions: [],
reactionsById: new Map(),
editedAtById: new Map([["PRR_1", "2026-07-04T00:00:00Z"]]),
reviewers: [],
avatarsByLogin: new Map(),
botLogins: new Set(["macroscopeapp"]),
Expand Down Expand Up @@ -1000,6 +1002,7 @@ describe("getChangeRequestActivity dismissed reviews", () => {
Effect.map((activity) => {
expect(activity.comments[0]?.body).toBe("Dismissing prior approval to re-evaluate 9b66581");
expect(activity.comments[0]?.author?.isBot).toBe(true);
expect(activity.comments[0]?.editedAt).toBe("2026-07-04T00:00:00Z");
}),
Effect.provide(layerFor("<!-- Macroscope (Approvability) review body marker -->")),
),
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ export const make = Effect.gen(function* () {
dismissalsByReviewId: new Map<string, string>(),
reactions: [],
reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(),
editedAtById: new Map<string, string>(),
reviewThreads: [],
commentCount: 0,
truncated: true,
Expand Down Expand Up @@ -473,6 +474,7 @@ export const make = Effect.gen(function* () {
// A comment out of `gh pr view --json` carries none of its own: that read
// reports no reaction at all, so they arrive from the GraphQL page by node id.
reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [],
editedAt: reviewThreads.editedAtById.get(comment.id) ?? comment.editedAt ?? null,
}))
.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)),
// `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two
Expand Down
34 changes: 33 additions & 1 deletion apps/server/src/pullRequest/gitHubPullRequestJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1105,7 +1105,13 @@ describe("review thread decoding", () => {
path: "src/a.ts",
line: 42,
diffSide: "LEFT",
comments: { totalCount: 2, nodes: [comment("c1", "first"), comment("c2", "second")] },
comments: {
totalCount: 2,
nodes: [
{ ...comment("c1", "first"), lastEditedAt: "2026-07-02T00:00:00Z" },
comment("c2", "second"),
],
},
},
]),
),
Expand All @@ -1124,6 +1130,7 @@ describe("review thread decoding", () => {
author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" },
body: "first",
createdAt: "2026-07-01T00:00:00Z",
editedAt: "2026-07-02T00:00:00Z",
url: "https://github.com/acme/web/pull/1#discussion_rc1",
reactions: [],
},
Expand All @@ -1132,12 +1139,16 @@ describe("review thread decoding", () => {
author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" },
body: "second",
createdAt: "2026-07-01T00:00:00Z",
editedAt: null,
url: "https://github.com/acme/web/pull/1#discussion_rc2",
reactions: [],
},
],
},
]);
expect(
reviewThreadConversation(reviewThreads.threads.map((entry) => entry.thread))[0]?.editedAt,
).toBe("2026-07-02T00:00:00Z");
});

it("leaves an outdated thread without a line rather than pinning it to a stale one", () => {
Expand Down Expand Up @@ -1186,6 +1197,27 @@ describe("review thread decoding", () => {
expect(threads).toHaveLength(1);
});

it("reads edit times for issue comments and reviews without using reaction updates", () => {
const editedAt = "2026-10-02T12:06:00Z";
const result = expectSuccess(
decodeReviewThreadsJson(
threadsJson([], {
comments: {
nodes: [
{ id: "edited", lastEditedAt: editedAt },
{ id: "reaction-only", lastEditedAt: null, updatedAt: editedAt },
],
},
reviews: { nodes: [{ id: "review", lastEditedAt: editedAt }] },
}),
),
);
expect([...result.editedAtById]).toEqual([
["edited", editedAt],
["review", editedAt],
]);
});

it("puts an issue comment's and a review's reactions in reactionsById, and the pull request's own in reactions", () => {
const result = expectSuccess(
decodeReviewThreadsJson(
Expand Down
23 changes: 19 additions & 4 deletions apps/server/src/pullRequest/gitHubPullRequestJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ function toReactions(

const RawCommentSchema = Schema.Struct({
id: Schema.String,
lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)),
author: Schema.optional(Schema.NullOr(RawActorSchema)),
body: Schema.optional(Schema.String),
createdAt: Schema.String,
Expand All @@ -372,6 +373,7 @@ const RawCommentSchema = Schema.Struct({

const RawReviewSchema = Schema.Struct({
id: Schema.String,
lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)),
author: Schema.optional(Schema.NullOr(RawActorSchema)),
body: Schema.optional(Schema.String),
state: Schema.optional(Schema.NullOr(Schema.String)),
Expand Down Expand Up @@ -490,6 +492,7 @@ const RawReviewThreadsSchema = Schema.Struct({
Schema.Struct({
id: Schema.optional(Schema.NullOr(Schema.String)),
author: Schema.optional(Schema.NullOr(RawActorSchema)),
lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)),
reactionGroups: RawReactionGroupsSchema,
}),
),
Expand All @@ -507,6 +510,7 @@ const RawReviewThreadsSchema = Schema.Struct({
Schema.Struct({
id: Schema.optional(Schema.NullOr(Schema.String)),
author: Schema.optional(Schema.NullOr(RawActorSchema)),
lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)),
reactionGroups: RawReactionGroupsSchema,
}),
),
Expand Down Expand Up @@ -885,7 +889,7 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin
comments(first: 10) {
totalCount
pageInfo { hasNextPage endCursor }
nodes { id author { __typename login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} }
nodes { id author { __typename login avatarUrl } body createdAt lastEditedAt url ${REACTION_GROUPS_FIELDS} }
}
}
}
Expand All @@ -894,9 +898,9 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin
author { __typename login avatarUrl }
${REACTION_GROUPS_FIELDS}
comments(first: ${GRAPHQL_PAGE_SIZE}) {
nodes { id author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} }
nodes { id lastEditedAt author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} }
}
reviews(first: ${GRAPHQL_PAGE_SIZE}) { nodes { id author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} } }
reviews(first: ${GRAPHQL_PAGE_SIZE}) { nodes { id lastEditedAt author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} } }
reviewRequests(first: 50) {
nodes {
requestedReviewer {
Expand Down Expand Up @@ -942,7 +946,7 @@ export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($owner: String!, $nam
pullRequest { id }
comments(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { id author { __typename login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} }
nodes { id author { __typename login avatarUrl } body createdAt lastEditedAt url ${REACTION_GROUPS_FIELDS} }
}
}
}
Expand Down Expand Up @@ -1542,6 +1546,7 @@ function toComments(raw: {
author: toActor(comment.author),
body: comment.body ?? "",
createdAt: comment.createdAt,
editedAt: comment.lastEditedAt ?? null,
url: trimmed(comment.url),
path: null,
reviewState: null,
Expand All @@ -1566,6 +1571,7 @@ function toComments(raw: {
author: toActor(review.author),
body: review.body ?? "",
createdAt: submittedAt,
editedAt: review.lastEditedAt ?? null,
url: trimmed(review.url),
path: null,
reviewState,
Expand Down Expand Up @@ -2097,6 +2103,7 @@ export interface GitHubReviewThreadComments {
readonly reactions: ReadonlyArray<PullRequestReaction>;
/** Reactions by node id, for the comments and reviews the `gh` JSON read carries no reaction on. */
readonly reactionsById: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
readonly editedAtById: ReadonlyMap<string, string>;
/**
* Everyone on the review: those still asked and those who have already answered. Whoever has
* reviewed is no longer an outstanding request, so asking only for requests reports nobody on
Expand Down Expand Up @@ -2146,6 +2153,7 @@ export interface GitHubReviewThreadPage {
* for without any. Only ids with a reaction are here; the rest carry none.
*/
readonly reactionsById: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
readonly editedAtById: ReadonlyMap<string, string>;
readonly reviewers: ReadonlyArray<PullRequestActor>;
readonly avatarsByLogin: ReadonlyMap<string, string>;
readonly botLogins: ReadonlySet<string>;
Expand Down Expand Up @@ -2176,6 +2184,7 @@ export function reviewThreadConversation(
author: comment.author,
body: comment.body,
createdAt: comment.createdAt,
editedAt: comment.editedAt ?? null,
url: comment.url,
path: thread.path,
reviewState: null,
Expand Down Expand Up @@ -2275,6 +2284,7 @@ export function decodeReviewThreadsJson(
author: toActor(comment.author),
body: comment.body ?? "",
createdAt: comment.createdAt,
editedAt: comment.lastEditedAt ?? null,
url: trimmed(comment.url),
reactions: toReactions(comment.reactionGroups, viewer),
})),
Expand Down Expand Up @@ -2341,12 +2351,15 @@ export function decodeReviewThreadsJson(
});
}
const reactionsById = new Map<string, ReadonlyArray<PullRequestReaction>>();
const editedAtById = new Map<string, string>();
for (const node of [
...(pullRequest.comments?.nodes ?? []),
...(pullRequest.reviews?.nodes ?? []),
]) {
const id = trimmed(node.id);
if (id === null) continue;
const editedAt = trimmed(node.lastEditedAt);
if (editedAt !== null) editedAtById.set(id, editedAt);
const reactions = toReactions(node.reactionGroups, viewer);
if (reactions.length > 0) reactionsById.set(id, reactions);
}
Expand All @@ -2355,6 +2368,7 @@ export function decodeReviewThreadsJson(
nextCursor: nextCursorOf(threads.pageInfo),
reactions: toReactions(pullRequest.reactionGroups, viewer),
reactionsById,
editedAtById,
reviewers: [...reviewers.values()],
avatarsByLogin,
botLogins,
Expand Down Expand Up @@ -2391,6 +2405,7 @@ export function decodeReviewThreadCommentsJson(raw: string): Result.Result<
author: toActor(comment.author),
body: comment.body ?? "",
createdAt: comment.createdAt,
editedAt: comment.lastEditedAt ?? null,
url: trimmed(comment.url),
reactions: toReactions(comment.reactionGroups, viewer),
})),
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/pullRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export const PullRequestComment = Schema.Struct({
author: Schema.NullOr(PullRequestActor),
body: Schema.String,
createdAt: IsoDateTime,
editedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
url: Schema.NullOr(Schema.String),
path: Schema.NullOr(Schema.String),
reviewState: Schema.NullOr(Schema.String),
Expand All @@ -228,6 +229,7 @@ export const PullRequestThreadComment = Schema.Struct({
author: Schema.NullOr(PullRequestActor),
body: Schema.String,
createdAt: IsoDateTime,
editedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
url: Schema.NullOr(Schema.String),
reactions: Schema.optional(Schema.Array(PullRequestReaction)),
});
Expand Down
Loading