Commit 31e5f22879

31e5f2287954aa632897456ef53eacdc9b4f47e8

parent: ce4ff56609

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-31 20:13 UTC

MR screens carry review, check and resolution times

The server reports them as of krz/gitbay!137: a created_at per review,
updated_at and duration per check, and merged_at/merged_by,
closed_at/closed_by on the merge request.

Reviews and checks now show the date and time they last said something,
a CI check shows how long its build ran, and a resolved MR shows who
merged or closed it and when. Every new field is optional, so an
instance that predates the server change decodes and renders as before.

Layout: unified · split

gitbay/MRs/MRModels.swift +27
@@ -37,6 +37,12 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
3737 let body: String?
3838 let milestone: String?
3939 let createdAt: Date
40 /// Set once the merge request is resolved. The actor is absent when
41 /// the account is gone, or the row was imported.
42 let mergedAt: Date?
43 let mergedBy: String?
44 let closedAt: Date?
45 let closedBy: String?
4046 let checks: [Check]?
4147 let checksCombined: String?
4248 let unresolvedThreads: Int?
@@ -49,6 +55,10 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
4955 case targetRef = "target_ref"
5056 case headSHA = "head_sha"
5157 case createdAt = "created_at"
58 case mergedAt = "merged_at"
59 case mergedBy = "merged_by"
60 case closedAt = "closed_at"
61 case closedBy = "closed_by"
5262 case checksCombined = "checks_combined"
5363 case unresolvedThreads = "unresolved_threads"
5464 }
@@ -59,6 +69,16 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
5969 let context: String
6070 let state: String
6171 let url: String?
72 /// When the check last changed state.
73 let updatedAt: Date?
74 /// How long the build behind the check ran, as the server
75 /// formats it ("1m12s"). Only CI checks have a build to time.
76 let duration: String?
77
78 enum CodingKeys: String, CodingKey {
79 case context, state, url, duration
80 case updatedAt = "updated_at"
81 }
6282 }
6383
6484 nonisolated struct MRCommit: Decodable, Sendable, Hashable, Identifiable {
@@ -86,6 +106,13 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
86106 let verdict: String
87107 /// The review predates the current head — it approved older code.
88108 let stale: Bool
109 let createdAt: Date?
110
111 enum CodingKeys: String, CodingKey {
112 case reviewer, verdict, stale
113 case createdAt = "created_at"
114 }
115
89116 var id: String { reviewer }
90117 }
91118}
gitbay/Views/MRs/MRView.swift +69 −18
@@ -113,11 +113,38 @@ struct MRView: View {
113113 }
114114 .font(.gbSans(.caption))
115115 .foregroundStyle(.secondary)
116 resolution(mr)
116117 }
117118 .padding(.vertical, 2)
118119 }
119120 }
120121
122 /// What became of the merge request, once it is no longer open. The
123 /// actor is absent for imported rows, which carry only a time.
124 @ViewBuilder
125 private func resolution(_ mr: MRDetail) -> some View {
126 if let at = mr.mergedAt {
127 stampLine("merged", by: mr.mergedBy, at: at)
128 } else if let at = mr.closedAt {
129 stampLine("closed without merging", by: mr.closedBy, at: at)
130 }
131 }
132
133 private func stampLine(_ verb: String, by actor: String?, at: Date) -> some View {
134 HStack(spacing: 6) {
135 Text(actor.map { "\(verb) by \($0)" } ?? verb)
136 Text(at, format: Self.stamp)
137 .foregroundStyle(.tertiary)
138 }
139 .font(.gbSans(.caption))
140 .foregroundStyle(.secondary)
141 }
142
143 /// Reviews and checks are read for when they happened, so they carry
144 /// the date rather than "3 days ago".
145 private static let stamp = Date.FormatStyle.dateTime
146 .year().month(.abbreviated).day().hour().minute()
147
121148 private func milestoneSection(_ mr: MRDetail) -> some View {
122149 Section("Milestone") {
123150 Menu {
@@ -179,15 +206,18 @@ struct MRView: View {
179206 private func checksSection(_ checks: [MRDetail.Check], combined: String?) -> some View {
180207 Section("Checks" + (combined.map { " — \($0)" } ?? "")) {
181208 ForEach(checks, id: \.context) { check in
182 HStack {
183 Image(systemName: checkIcon(check.state))
184 .foregroundStyle(checkColor(check.state))
185 Text(check.context)
186 .font(.gbSans(.subheadline))
187 Spacer()
188 Text(check.state)
189 .font(.gbSans(.caption))
190 .foregroundStyle(.secondary)
209 VStack(alignment: .leading, spacing: 2) {
210 HStack {
211 Image(systemName: checkIcon(check.state))
212 .foregroundStyle(checkColor(check.state))
213 Text(check.context)
214 .font(.gbSans(.subheadline))
215 Spacer()
216 Text(check.state)
217 .font(.gbSans(.caption))
218 .foregroundStyle(.secondary)
219 }
220 timing(check.updatedAt, duration: check.duration)
191221 }
192222 }
193223 }
@@ -196,18 +226,39 @@ struct MRView: View {
196226 private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View {
197227 Section("Reviews") {
198228 ForEach(reviews) { review in
199 HStack {
200 Image(systemName: review.verdict == "approve"
201 ? "checkmark.circle.fill" : "exclamationmark.circle.fill")
202 .foregroundStyle(review.verdict == "approve" ? Color.gbOK : Color.gbWarn)
203 Text(review.reviewer)
204 .font(.gbSans(.subheadline))
205 Spacer()
206 if review.stale {
207 GBChip("stale", .gbWarn)
229 VStack(alignment: .leading, spacing: 2) {
230 HStack {
231 Image(systemName: review.verdict == "approve"
232 ? "checkmark.circle.fill" : "exclamationmark.circle.fill")
233 .foregroundStyle(review.verdict == "approve" ? Color.gbOK : Color.gbWarn)
234 Text(review.reviewer)
235 .font(.gbSans(.subheadline))
236 Spacer()
237 if review.stale {
238 GBChip("stale", .gbWarn)
239 }
208240 }
241 timing(review.createdAt)
242 }
243 }
244 }
245 }
246
247 /// When a review or check last said something, and for a CI check how
248 /// long its build ran. Nothing renders against a server that does not
249 /// report the time.
250 @ViewBuilder
251 private func timing(_ at: Date?, duration: String? = nil) -> some View {
252 if let at {
253 HStack(spacing: 6) {
254 Text(at, format: Self.stamp)
255 if let duration {
256 Text("·")
257 Text(duration)
209258 }
210259 }
260 .font(.gbSans(.caption))
261 .foregroundStyle(.tertiary)
211262 }
212263 }
213264
gitbayTests/MRViewModelTests.swift +49 −2
@@ -27,11 +27,21 @@ private let mrShowJSON = """
2727 {"protocol_version":1,"data":{"number":7,"title":"client: envelope decoding",\
2828 "state":"open","author":"cmc","source":"client-envelope","target_ref":"main",\
2929 "head_sha":"aabbcc","body":"Speaks both surfaces.","created_at":"2026-08-20T10:00:00.000Z",\
30 "checks":[{"context":"build","state":"success"}],"checks_combined":"success",\
30 "checks":[{"context":"ci/build","state":"success","updated_at":"2026-08-20T10:05:00.000Z",\
31 "duration":"1m12s"},{"context":"external/lint","state":"success",\
32 "updated_at":"2026-08-20T10:06:00.000Z"}],"checks_combined":"success",\
3133 "unresolved_threads":1,\
3234 "commits":[{"sha":"aabbcc00000000000000","subject":"client: envelope"}],\
3335 "comments":[{"author":"krz","body":"looks right","created_at":"2026-08-20T11:00:00.000Z"}],\
34 "reviews":[{"reviewer":"krz","verdict":"approve","stale":false}]},"exit_code":0}
36 "reviews":[{"reviewer":"krz","verdict":"approve","stale":false,\
37 "created_at":"2026-08-20T10:30:00.000Z"}]},"exit_code":0}
38 """
39
40private let mrMergedJSON = """
41 {"protocol_version":1,"data":{"number":7,"title":"client: envelope decoding",\
42 "state":"merged","author":"cmc","source":"client-envelope","target_ref":"main",\
43 "head_sha":"aabbcc","created_at":"2026-08-20T10:00:00.000Z",\
44 "merged_at":"2026-08-21T09:15:00.000Z","merged_by":"cmc"},"exit_code":0}
3545 """
3646
3747private let threadsJSON = """
@@ -194,11 +204,48 @@ struct MRDetailViewModelTests {
194204 #expect(mr.title == "client: envelope decoding")
195205 #expect(mr.checksCombined == "success")
196206 #expect(mr.reviews?.first?.verdict == "approve")
207 #expect(mr.reviews?.first?.createdAt != nil)
197208 #expect(model.diff?.files.count == 2)
198209 #expect(model.threads.count == 2)
199210 #expect(model.unresolvedCount == 1)
200211 }
201212
213 /// A CI check reports how long its build ran; anything else reports
214 /// only when it last changed. Neither may borrow the other's timing.
215 @Test func checksCarryTheirTiming() async throws {
216 let (model, _) = try await loadedModel()
217
218 let checks = try #require(model.state.value?.checks)
219 let ci = try #require(checks.first { $0.context == "ci/build" })
220 #expect(ci.duration == "1m12s")
221 #expect(ci.updatedAt != nil)
222 let external = try #require(checks.first { $0.context == "external/lint" })
223 #expect(external.duration == nil)
224 #expect(external.updatedAt != nil)
225 }
226
227 /// A merged MR says who merged it and when. An open one has no stamp
228 /// to show, and must not invent one.
229 @Test func resolutionStampsDecode() async throws {
230 let (client, stub) = try makeClient()
231 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
232 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
233 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
234 let open = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
235 await open.load()
236 #expect(open.state.value?.mergedAt == nil)
237 #expect(open.state.value?.closedAt == nil)
238
239 let (mergedClient, mergedStub) = try makeClient()
240 mergedStub.enqueue(.init(status: 200, json: mrMergedJSON, match: "argv=show"))
241 mergedStub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
242 mergedStub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
243 let merged = MRDetailViewModel(client: mergedClient, repoPath: "krz/gitbay", number: 7)
244 await merged.load()
245 #expect(merged.state.value?.mergedBy == "cmc")
246 #expect(merged.state.value?.mergedAt != nil)
247 }
248
202249 @Test func approveSendsTheWriteThenReloads() async throws {
203250 let (model, stub) = try await loadedModel()
204251 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))