Skip to content

Commit c98f969

Browse files
authored
Merge pull request #2335 from argos-ci/greg/arg-442-display-argos-comments-in-github-comments
feat(backend): highlight reviewers in GitHub PR comments
2 parents 58b27a7 + cf23305 commit c98f969

2 files changed

Lines changed: 274 additions & 5 deletions

File tree

apps/backend/src/git-platform/comment.e2e.test.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ async function createBuild(input: {
2828
projectId: string;
2929
name?: string;
3030
updatedAt?: string;
31+
conclusion?: Build["conclusion"];
3132
}): Promise<Build> {
3233
const compareBucket = await factory.ScreenshotBucket.create({
3334
projectId: input.projectId,
@@ -38,11 +39,21 @@ async function createBuild(input: {
3839
compareScreenshotBucketId: compareBucket.id,
3940
name: input.name ?? "default",
4041
jobStatus: "complete",
41-
conclusion: "no-changes",
42+
conclusion: input.conclusion ?? "no-changes",
4243
updatedAt: input.updatedAt,
4344
});
4445
}
4546

47+
/**
48+
* Create a user with an account named `name`, so their reviews show a display
49+
* name in the PR comment.
50+
*/
51+
async function createReviewer(name: string) {
52+
const user = await factory.User.create();
53+
await factory.UserAccount.create({ userId: user.id, name });
54+
return user;
55+
}
56+
4657
async function createDeployment(input: {
4758
projectId: string;
4859
slug: string;
@@ -182,4 +193,92 @@ describe("getCommentBody", () => {
182193
expect(body).toContain(`| **${project.name}/default**`);
183194
expect(body).toContain(`| **${otherProject.name}/production**`);
184195
});
196+
197+
test("shows the reviewers and comment count for an approved build", async ({
198+
project,
199+
}) => {
200+
const [build, alice, bob] = await Promise.all([
201+
createBuild({
202+
projectId: project.id,
203+
conclusion: "changes-detected",
204+
updatedAt: "2026-05-07T12:00:00.000Z",
205+
}),
206+
createReviewer("Alice"),
207+
createReviewer("Bob"),
208+
]);
209+
await factory.BuildReview.createMany(2, [
210+
{ buildId: build.id, userId: alice.id, state: "approved" },
211+
{ buildId: build.id, userId: bob.id, state: "approved" },
212+
]);
213+
const rootComment = await factory.Comment.create({
214+
buildId: build.id,
215+
userId: alice.id,
216+
});
217+
await factory.Comment.createMany(2, [
218+
{ buildId: build.id, userId: bob.id },
219+
// A reply must not inflate the count.
220+
{ buildId: build.id, userId: bob.id, threadId: rootComment.id },
221+
]);
222+
223+
const buildUrl = await build.getUrl();
224+
const body = await getCommentBody({ commit });
225+
226+
expect(body).toContain(
227+
`| **default** ([Inspect](${buildUrl})) | 👍 Approved by Alice and Bob (2 comments) | - | May 7, 2026, 12:00 PM |`,
228+
);
229+
});
230+
231+
test("shows the reviewer who rejected a build", async ({ project }) => {
232+
const [build, carol] = await Promise.all([
233+
createBuild({
234+
projectId: project.id,
235+
conclusion: "changes-detected",
236+
updatedAt: "2026-05-07T12:00:00.000Z",
237+
}),
238+
createReviewer("Carol"),
239+
]);
240+
await factory.BuildReview.create({
241+
buildId: build.id,
242+
userId: carol.id,
243+
state: "rejected",
244+
});
245+
246+
const buildUrl = await build.getUrl();
247+
const body = await getCommentBody({ commit });
248+
249+
expect(body).toContain(
250+
`| **default** ([Inspect](${buildUrl})) | 👎 Rejected by Carol | - | May 7, 2026, 12:00 PM |`,
251+
);
252+
});
253+
254+
test("ignores dismissed reviews when listing reviewers", async ({
255+
project,
256+
}) => {
257+
const [build, alice, bob] = await Promise.all([
258+
createBuild({
259+
projectId: project.id,
260+
conclusion: "changes-detected",
261+
updatedAt: "2026-05-07T12:00:00.000Z",
262+
}),
263+
createReviewer("Alice"),
264+
createReviewer("Bob"),
265+
]);
266+
await factory.BuildReview.createMany(2, [
267+
{
268+
buildId: build.id,
269+
userId: alice.id,
270+
state: "approved",
271+
dismissedAt: "2026-05-07T12:30:00.000Z",
272+
dismissedById: bob.id,
273+
},
274+
{ buildId: build.id, userId: bob.id, state: "approved" },
275+
]);
276+
277+
const buildUrl = await build.getUrl();
278+
const body = await getCommentBody({ commit });
279+
280+
expect(body).toContain(
281+
`| **default** ([Inspect](${buildUrl})) | 👍 Approved by Bob | - | May 7, 2026, 12:00 PM |`,
282+
);
283+
});
185284
});

apps/backend/src/git-platform/comment.ts

Lines changed: 174 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1+
import type { BuildAggregatedStatus } from "@argos/schemas/build-status";
2+
import type { BuildType } from "@argos/schemas/build-type";
13
import { invariant } from "@argos/util/invariant";
24

3-
import { getBuildLabel } from "@/build/label";
5+
import { getApprovalEmoji, getBuildLabel } from "@/build/label";
46
import { getStatsMessage } from "@/build/stats";
57
import { getCommentHeader } from "@/database";
6-
import { Build, Deployment, Project } from "@/database/models";
8+
import {
9+
Build,
10+
BuildReview,
11+
Comment,
12+
Deployment,
13+
Project,
14+
} from "@/database/models";
715

816
const dateFormatter = Intl.DateTimeFormat("en-US", {
917
dateStyle: "medium",
@@ -53,12 +61,168 @@ function getDeploymentLabel(deployment: Deployment): string {
5361
}
5462
}
5563

64+
const reviewerListFormatter = new Intl.ListFormat("en-US", {
65+
style: "long",
66+
type: "conjunction",
67+
});
68+
69+
/**
70+
* Who reviewed a build and how much discussion it drew, used to enrich the
71+
* status column of the PR comment.
72+
*/
73+
type BuildReviewSummary = {
74+
/** Display names of users whose latest active review approved the build. */
75+
approvedBy: string[];
76+
/** Display names of users whose latest active review rejected the build. */
77+
rejectedBy: string[];
78+
/** Number of comment threads (root comments) on the build. */
79+
commentCount: number;
80+
};
81+
82+
function getOrCreateReviewSummary(
83+
summaries: Map<string, BuildReviewSummary>,
84+
buildId: string,
85+
): BuildReviewSummary {
86+
let summary = summaries.get(buildId);
87+
if (!summary) {
88+
summary = { approvedBy: [], rejectedBy: [], commentCount: 0 };
89+
summaries.set(buildId, summary);
90+
}
91+
return summary;
92+
}
93+
94+
/**
95+
* Build a review summary per build: the reviewers grouped by their latest active
96+
* decision and the number of comment threads. Mirrors the aggregation done in
97+
* {@link Build.getReviewStatuses} (latest review per user, dismissed reviews
98+
* ignored) so the names shown match the aggregated status.
99+
*/
100+
async function getBuildReviewSummaries(
101+
builds: Build[],
102+
): Promise<Map<string, BuildReviewSummary>> {
103+
const summaries = new Map<string, BuildReviewSummary>();
104+
const buildIds = builds.map((build) => build.id);
105+
if (buildIds.length === 0) {
106+
return summaries;
107+
}
108+
109+
const [reviews, commentCounts] = await Promise.all([
110+
BuildReview.query()
111+
.select(
112+
"build_reviews.buildId",
113+
"build_reviews.state",
114+
"build_reviews.dismissedAt",
115+
"accounts.name as reviewerName",
116+
"accounts.slug as reviewerSlug",
117+
)
118+
.distinctOn(["build_reviews.buildId", "build_reviews.userId"])
119+
.leftJoin("accounts", "accounts.userId", "build_reviews.userId")
120+
.whereIn("build_reviews.buildId", buildIds)
121+
.orderBy("build_reviews.buildId")
122+
.orderBy("build_reviews.userId")
123+
.orderBy("build_reviews.createdAt", "desc")
124+
.orderBy("build_reviews.id", "desc") as unknown as Promise<
125+
{
126+
buildId: string;
127+
state: BuildReview["state"];
128+
dismissedAt: string | null;
129+
reviewerName: string | null;
130+
reviewerSlug: string | null;
131+
}[]
132+
>,
133+
Comment.query()
134+
.select("buildId")
135+
.count("* as count")
136+
.whereIn("buildId", buildIds)
137+
.whereNull("deletedAt")
138+
// Only count thread roots so a discussion with replies stays "1 comment".
139+
.whereNull("threadId")
140+
.groupBy("buildId") as unknown as Promise<
141+
{ buildId: string; count: string | number }[]
142+
>,
143+
]);
144+
145+
for (const review of reviews) {
146+
// The latest active review per user wins; a dismissed latest review means
147+
// the user no longer has an active decision.
148+
if (review.dismissedAt) {
149+
continue;
150+
}
151+
if (review.state !== "approved" && review.state !== "rejected") {
152+
continue;
153+
}
154+
const name = review.reviewerName || review.reviewerSlug;
155+
if (!name) {
156+
continue;
157+
}
158+
const summary = getOrCreateReviewSummary(summaries, review.buildId);
159+
const list =
160+
review.state === "approved" ? summary.approvedBy : summary.rejectedBy;
161+
list.push(name);
162+
}
163+
164+
for (const { buildId, count } of commentCounts) {
165+
getOrCreateReviewSummary(summaries, buildId).commentCount = Number(count);
166+
}
167+
168+
return summaries;
169+
}
170+
171+
/**
172+
* Append the number of comment threads, e.g. " (3 comments)".
173+
*/
174+
function formatCommentCount(count: number): string {
175+
if (count <= 0) {
176+
return "";
177+
}
178+
return ` (${count} comment${count === 1 ? "" : "s"})`;
179+
}
180+
181+
/**
182+
* Status label for the build, enriched with the reviewers when the build has
183+
* been approved or rejected, e.g. "👍 Approved by Alice and Bob (3 comments)".
184+
* Falls back to the plain {@link getBuildLabel} for every other status.
185+
*/
186+
function getReviewAwareBuildLabel(input: {
187+
type: BuildType | null;
188+
status: BuildAggregatedStatus;
189+
summary: BuildReviewSummary | undefined;
190+
}): string {
191+
const { type, status, summary } = input;
192+
// Only "check" builds (and legacy builds with no type) carry reviews; other
193+
// types always resolve to a fixed label.
194+
if (type === "check" || type == null) {
195+
if (status === "accepted") {
196+
const label = formatReviewLabel("approved", summary?.approvedBy ?? []);
197+
return `${label}${formatCommentCount(summary?.commentCount ?? 0)}`;
198+
}
199+
if (status === "rejected") {
200+
const label = formatReviewLabel("rejected", summary?.rejectedBy ?? []);
201+
return `${label}${formatCommentCount(summary?.commentCount ?? 0)}`;
202+
}
203+
}
204+
return getBuildLabel(type, status);
205+
}
206+
207+
function formatReviewLabel(
208+
state: "approved" | "rejected",
209+
names: string[],
210+
): string {
211+
const emoji = getApprovalEmoji(state);
212+
const verb = state === "approved" ? "Approved" : "Rejected";
213+
if (names.length === 0) {
214+
return `${emoji} ${verb}`;
215+
}
216+
return `${emoji} ${verb} by ${reviewerListFormatter.format(names)}`;
217+
}
218+
56219
function getBuildRows(input: {
57220
builds: Build[];
58221
hasMultipleProjects: boolean;
59222
aggregateStatuses: Awaited<
60223
ReturnType<typeof Build.getAggregatedBuildStatuses>
61224
>;
225+
reviewSummaries: Map<string, BuildReviewSummary>;
62226
urls: string[];
63227
}) {
64228
return input.builds
@@ -79,7 +243,11 @@ function getBuildRows(input: {
79243
? getStatsMessage(build.stats, { isSubsetBuild: build.subset })
80244
: null;
81245

82-
const label = getBuildLabel(build.type, status);
246+
const label = getReviewAwareBuildLabel({
247+
type: build.type,
248+
status,
249+
summary: input.reviewSummaries.get(build.id),
250+
});
83251
const review =
84252
status === "changes-detected" && build.type !== "reference"
85253
? ` ([Review](${url}))`
@@ -150,14 +318,16 @@ export async function getCommentBody(props: {
150318
...builds.map((build) => build.projectId),
151319
...deployments.map((deployment) => deployment.projectId),
152320
]).size > 1;
153-
const [aggregateStatuses, urls] = await Promise.all([
321+
const [aggregateStatuses, urls, reviewSummaries] = await Promise.all([
154322
Build.getAggregatedBuildStatuses(builds),
155323
Promise.all(builds.map((build) => build.getUrl())),
324+
getBuildReviewSummaries(builds),
156325
]);
157326
const buildRows = getBuildRows({
158327
builds,
159328
hasMultipleProjects,
160329
aggregateStatuses,
330+
reviewSummaries,
161331
urls,
162332
});
163333
const deploymentRows = getDeploymentRows({

0 commit comments

Comments
 (0)