1+ import type { BuildAggregatedStatus } from "@argos/schemas/build-status" ;
2+ import type { BuildType } from "@argos/schemas/build-type" ;
13import { invariant } from "@argos/util/invariant" ;
24
3- import { getBuildLabel } from "@/build/label" ;
5+ import { getApprovalEmoji , getBuildLabel } from "@/build/label" ;
46import { getStatsMessage } from "@/build/stats" ;
57import { 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
816const 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+
56219function 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