gitbay/Views/MRs/MRView.swift
709 lines · 28429 bytes
1import SwiftUI
2
3struct MRView: View {
4
5 @State private var model: MRDetailViewModel
6 @State private var commentText = ""
7 @State private var commentFormat = "md"
8 @State private var confirmingMerge = false
9 @State private var confirmingClose = false
10 @State private var editing = false
11 @State private var draftTitle = ""
12 @State private var draftBody = ""
13 @State private var draftFormat = "md"
14 @State private var editingReviewer = ""
15 @State private var retargeting = false
16 @State private var pendingRetarget: String?
17
18 init(client: GitbayClient, repo: String, number: Int64) {
19 _model = State(initialValue: MRDetailViewModel(
20 client: client, repoPath: repo, number: number
21 ))
22 }
23
24 var body: some View {
25 List {
26 if let mr = model.state.value {
27 header(mr)
28
29 if let error = model.actionError {
30 Section {
31 GBNotice(error, .gbWarn)
32 }
33 }
34
35 if let body = mr.body, !body.isEmpty {
36 Section {
37 FormattedBodyView(text: body, format: mr.bodyFormat)
38 .padding(.vertical, 4)
39 }
40 }
41
42 milestoneSection(mr)
43 stackSection(mr)
44 reviewersSection(mr)
45 diffSection
46 if !model.revisions.isEmpty {
47 revisionsSection
48 }
49
50 if let commits = mr.commits, !commits.isEmpty {
51 commitsSection(commits)
52 }
53 if let checks = mr.checks, !checks.isEmpty {
54 checksSection(checks, combined: mr.checksCombined)
55 }
56 if let gates = mr.gates, mr.isOpen {
57 gatesSection(gates)
58 }
59 if let reviews = mr.reviews, !reviews.isEmpty {
60 reviewsSection(reviews)
61 }
62 if !model.threads.isEmpty {
63 threadsSection
64 }
65 commentsSection(mr.comments ?? [])
66 }
67 }
68 .overlay { LoadStateOverlay(state: model.state) }
69 .navigationTitle("!\(model.number)")
70 .navigationBarTitleDisplayMode(.inline)
71 .toolbar { toolbar }
72 .task { await model.load() }
73 .refreshable { await model.load() }
74 .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) {
75 if let mr = model.state.value, !mr.stacked.isEmpty {
76 Button("Merge") { Task { await model.merge() } }
77 Button("Fast-forward") { Task { await model.merge(strategy: "ff") } }
78 } else {
79 Button("Merge") { Task { await model.merge() } }
80 Button("Squash") { Task { await model.merge(strategy: "squash") } }
81 Button("Fast-forward") { Task { await model.merge(strategy: "ff") } }
82 Button("Rebase") { Task { await model.merge(strategy: "rebase") } }
83 }
84 Button("Cancel", role: .cancel) {}
85 } message: {
86 if let mr = model.state.value, !mr.stacked.isEmpty {
87 Text("Merge requests are stacked on this one. Squash and rebase are unavailable — they would rewrite the commits the stack builds on.")
88 } else {
89 Text("The server enforces approvals, threads and checks — a refusal will say why.")
90 }
91 }
92 .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) {
93 Button("Close", role: .destructive) { Task { await model.close() } }
94 Button("Cancel", role: .cancel) {}
95 }
96 .confirmationDialog(
97 "Retarget to \(pendingRetarget ?? "")?",
98 isPresented: .init(
99 get: { pendingRetarget != nil },
100 set: { if !$0 { pendingRetarget = nil } }
101 ),
102 titleVisibility: .visible
103 ) {
104 Button("Retarget") {
105 guard let branch = pendingRetarget else { return }
106 pendingRetarget = nil
107 Task { await model.retarget(to: branch) }
108 }
109 Button("Cancel", role: .cancel) { pendingRetarget = nil }
110 } message: {
111 Text("Existing approvals were of the diff against the old target and will no longer apply.")
112 }
113 .sheet(isPresented: $retargeting) {
114 RetargetSheet(model: model) { branch in
115 retargeting = false
116 pendingRetarget = branch
117 }
118 }
119 .sheet(isPresented: $editing) {
120 ComposeSheet(
121 heading: "Edit !\(model.number)",
122 submitLabel: "Save",
123 working: model.working,
124 errorMessage: model.actionError,
125 title: $draftTitle,
126 bodyText: $draftBody,
127 format: $draftFormat
128 ) {
129 Task {
130 await model.edit(title: draftTitle, body: draftBody, format: draftFormat)
131 if model.actionError == nil { editing = false }
132 }
133 }
134 }
135 }
136
137 // MARK: - Sections
138
139 @ViewBuilder
140 private func header(_ mr: MRDetail) -> some View {
141 Section {
142 VStack(alignment: .leading, spacing: 6) {
143 Text(mr.title)
144 .font(.gbSans(.headline))
145 HStack(spacing: 6) {
146 MRStateBadge(state: mr.state)
147 if mr.draft {
148 GBChip("draft", .secondary)
149 }
150 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
151 .lineLimit(1)
152 Image(systemName: "arrow.right")
153 .font(.gbSans(.caption2))
154 Text(mr.targetRef)
155 }
156 .font(.gbSans(.caption))
157 .foregroundStyle(.secondary)
158 HStack(spacing: 6) {
159 Text("by \(mr.author)")
160 Text(mr.createdAt, format: .relative(presentation: .named))
161 .foregroundStyle(.tertiary)
162 }
163 .font(.gbSans(.caption))
164 .foregroundStyle(.secondary)
165 resolution(mr)
166 }
167 .padding(.vertical, 2)
168 }
169 }
170
171 /// What became of the merge request, once it is no longer open. The
172 /// actor is absent for imported rows, which carry only a time.
173 @ViewBuilder
174 private func resolution(_ mr: MRDetail) -> some View {
175 if let at = mr.mergedAt {
176 stampLine("merged", by: mr.mergedBy, at: at)
177 } else if let at = mr.closedAt {
178 stampLine("closed without merging", by: mr.closedBy, at: at)
179 }
180 }
181
182 private func stampLine(_ verb: String, by actor: String?, at: Date) -> some View {
183 HStack(spacing: 6) {
184 Text(actor.map { "\(verb) by \($0)" } ?? verb)
185 Text(at, format: Self.stamp)
186 .foregroundStyle(.tertiary)
187 }
188 .font(.gbSans(.caption))
189 .foregroundStyle(.secondary)
190 }
191
192 /// Reviews and checks are read for when they happened, so they carry
193 /// the date rather than "3 days ago".
194 private static let stamp = Date.FormatStyle.dateTime
195 .year().month(.abbreviated).day().hour().minute()
196
197 private func milestoneSection(_ mr: MRDetail) -> some View {
198 Section("Milestone") {
199 Menu {
200 Button("None") { Task { await model.setMilestone(nil) } }
201 ForEach(model.availableMilestones ?? []) { milestone in
202 Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") {
203 Task { await model.setMilestone(milestone.title) }
204 }
205 }
206 } label: {
207 HStack {
208 Label(mr.milestone ?? "None", systemImage: "flag")
209 .font(.gbSans(.subheadline))
210 Spacer()
211 Image(systemName: "chevron.up.chevron.down")
212 .font(.gbSans(.caption2))
213 .foregroundStyle(.secondary)
214 }
215 }
216 .disabled(model.working)
217 .task { await model.loadMilestones() }
218 .accessibilityIdentifier("mr-milestone-menu")
219 }
220 }
221
222 /// Shown only when this merge request is part of a stack. Each row
223 /// spells out its own direction — "stacked on" vs. "is stacked on
224 /// this" mean opposite things and look alike if abbreviated.
225 @ViewBuilder
226 private func stackSection(_ mr: MRDetail) -> some View {
227 if mr.stackedOn != nil || !mr.stacked.isEmpty {
228 Section("Stack") {
229 if let base = mr.stackedOn {
230 NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: base.number)) {
231 Label("stacked on !\(base.number) — \(base.title)", systemImage: "arrow.up.to.line")
232 .font(.gbSans(.subheadline))
233 .lineLimit(1)
234 }
235 }
236 ForEach(mr.stacked) { ref in
237 NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: ref.number)) {
238 Label("!\(ref.number) is stacked on this — \(ref.title)", systemImage: "arrow.down.to.line")
239 .font(.gbSans(.subheadline))
240 .lineLimit(1)
241 }
242 }
243 }
244 }
245 }
246
247 /// Every head the merge request has had. A revision after the first
248 /// links to the range-diff from the one before it.
249 private var revisionsSection: some View {
250 Section("Revisions") {
251 ForEach(Array(model.revisions.enumerated()), id: \.element.id) { index, revision in
252 let row = HStack {
253 Text("v\(revision.n)")
254 .font(.gbSans(.subheadline).weight(.medium))
255 Text(String(revision.sha.prefix(10)))
256 .font(.gbMono(.caption))
257 .foregroundStyle(.secondary)
258 Spacer()
259 if revision.isCurrent {
260 GBChip("current", .secondary)
261 }
262 Text(revision.createdAt.prefix(10))
263 .font(.gbSans(.caption))
264 .foregroundStyle(.tertiary)
265 }
266 if index > 0 {
267 NavigationLink(value: MRRoute.rangeDiff(
268 repo: model.repoPath, number: model.number,
269 from: model.revisions[index - 1].sha, to: revision.sha)
270 ) {
271 row
272 }
273 } else {
274 row
275 }
276 }
277 }
278 }
279
280 private func reviewersSection(_ mr: MRDetail) -> some View {
281 Section("Reviewers") {
282 reviewerFlow(mr.reviewRequests)
283 HStack {
284 TextField("Request review", text: $editingReviewer)
285 .autocorrectionDisabled()
286 .textInputAutocapitalization(.never)
287 Button {
288 let user = editingReviewer.trimmingCharacters(in: .whitespaces)
289 editingReviewer = ""
290 Task { await model.requestReview(from: user) }
291 } label: {
292 Image(systemName: "plus.circle.fill")
293 }
294 .disabled(editingReviewer.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
295 }
296 }
297 }
298
299 /// The same add/remove chip shape `IssueView` uses for labels and
300 /// assignees.
301 @ViewBuilder
302 private func reviewerFlow(_ items: [String]) -> some View {
303 if !items.isEmpty {
304 ScrollView(.horizontal, showsIndicators: false) {
305 HStack(spacing: 6) {
306 ForEach(items, id: \.self) { item in
307 HStack(spacing: 3) {
308 Text(item)
309 Button {
310 Task { await model.removeReviewRequest(item) }
311 } label: {
312 Image(systemName: "xmark.circle.fill")
313 .foregroundStyle(.tertiary)
314 }
315 .disabled(model.working)
316 }
317 .font(.gbSans(.caption))
318 .padding(.horizontal, 8)
319 .padding(.vertical, 3)
320 .background(Color.secondary.opacity(0.07), in: gbChipShape)
321 .overlay(gbChipShape.stroke(Color.secondary.opacity(0.35), lineWidth: 1))
322 }
323 }
324 }
325 }
326 }
327
328 private var diffSection: some View {
329 Section {
330 NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) {
331 HStack {
332 Label("Diff", systemImage: "plus.forwardslash.minus")
333 Spacer()
334 if let diff = model.diff {
335 Text("+\(diff.additions)")
336 .foregroundStyle(Color.gbOK)
337 Text("−\(diff.deletions)")
338 .foregroundStyle(Color.gbBad)
339 }
340 }
341 .font(.gbSans(.subheadline))
342 }
343 }
344 }
345
346 private func commitsSection(_ commits: [MRDetail.MRCommit]) -> some View {
347 Section("Commits") {
348 ForEach(commits) { commit in
349 HStack(spacing: 8) {
350 Text(commit.shortSHA)
351 .font(.gbMono(.caption))
352 .foregroundStyle(.secondary)
353 Text(commit.subject)
354 .font(.gbSans(.subheadline))
355 .lineLimit(1)
356 }
357 }
358 }
359 }
360
361 private func checksSection(_ checks: [MRDetail.Check], combined: String?) -> some View {
362 Section("Checks" + (combined.map { " — \($0)" } ?? "")) {
363 ForEach(checks, id: \.context) { check in
364 VStack(alignment: .leading, spacing: 2) {
365 HStack {
366 Image(systemName: checkIcon(check.state))
367 .foregroundStyle(checkColor(check.state))
368 Text(check.context)
369 .font(.gbSans(.subheadline))
370 Spacer()
371 Text(check.state)
372 .font(.gbSans(.caption))
373 .foregroundStyle(.secondary)
374 }
375 timing(check.updatedAt, duration: check.duration)
376 }
377 }
378 }
379 }
380
381 /// The merge gates as the server will judge them, each row met or
382 /// not, so a refusal is never the first time a rule is seen.
383 private func gatesSection(_ gates: MRDetail.Gates) -> some View {
384 Section {
385 if gates.draft {
386 gateRow(false, "Draft — not asking to merge")
387 }
388 if gates.approvalsRequired > 0 {
389 gateRow(gates.approvals.count >= gates.approvalsRequired,
390 "\(gates.approvals.count) of \(gates.approvalsRequired) approval\(gates.approvalsRequired == 1 ? "" : "s")"
391 + (gates.approvals.isEmpty ? "" : " — " + gates.approvals.joined(separator: ", ")))
392 }
393 if !gates.changesRequested.isEmpty {
394 gateRow(false, "Changes requested by " + gates.changesRequested.joined(separator: ", "))
395 }
396 if gates.codeownersRequired {
397 if gates.ownersOutstanding.isEmpty {
398 gateRow(true, "Code owners approved")
399 } else {
400 ForEach(gates.ownersOutstanding) { owners in
401 gateRow(false, owners.files.joined(separator: ", ") + " — " + owners.owners.joined(separator: ", "))
402 }
403 }
404 }
405 if gates.resolvedRequired {
406 gateRow(gates.openThreads == 0,
407 gates.openThreads == 0 ? "Threads resolved" : "\(gates.openThreads) thread\(gates.openThreads == 1 ? "" : "s") open")
408 }
409 if gates.checksRequired {
410 gateRow(gates.checks == "success", "Checks " + (gates.checks ?? "not reported"))
411 }
412 gateRow(gates.fastForward, gates.fastForward ? "Fast-forward possible" : "Behind the target — rebase first")
413 } header: {
414 Text("Gates")
415 } footer: {
416 Text(gates.isMet ? "Ready to merge." : "Unmet: " + gates.unmet.joined(separator: ", "))
417 }
418 }
419
420 private func gateRow(_ met: Bool, _ text: String) -> some View {
421 HStack(alignment: .firstTextBaseline) {
422 Image(systemName: met ? "checkmark.circle.fill" : "circle")
423 .foregroundStyle(met ? Color.gbOK : Color.gbWarn)
424 Text(text)
425 .font(.gbSans(.subheadline))
426 }
427 }
428
429 private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View {
430 Section("Reviews") {
431 ForEach(reviews) { review in
432 VStack(alignment: .leading, spacing: 2) {
433 HStack {
434 Image(systemName: review.verdict == "approve"
435 ? "checkmark.circle.fill" : "exclamationmark.circle.fill")
436 .foregroundStyle(review.verdict == "approve" ? Color.gbOK : Color.gbWarn)
437 Text(review.reviewer)
438 .font(.gbSans(.subheadline))
439 Spacer()
440 if review.stale {
441 GBChip("stale", .gbWarn)
442 }
443 if !review.counts {
444 GBChip("advisory", .secondary)
445 }
446 }
447 timing(review.createdAt)
448 }
449 }
450 }
451 }
452
453 /// When a review or check last said something, and for a CI check how
454 /// long its build ran. Nothing renders against a server that does not
455 /// report the time.
456 @ViewBuilder
457 private func timing(_ at: Date?, duration: String? = nil) -> some View {
458 if let at {
459 HStack(spacing: 6) {
460 Text(at, format: Self.stamp)
461 if let duration {
462 Text("·")
463 Text(duration)
464 }
465 }
466 .font(.gbSans(.caption))
467 .foregroundStyle(.tertiary)
468 }
469 }
470
471 private var threadsSection: some View {
472 Section("Threads — \(model.unresolvedCount) unresolved") {
473 ForEach(model.threads) { thread in
474 ReviewThreadView(thread: thread, model: model)
475 }
476 }
477 }
478
479 private func commentsSection(_ comments: [MRDetail.MRComment]) -> some View {
480 Section("Comments") {
481 ForEach(comments) { comment in
482 VStack(alignment: .leading, spacing: 4) {
483 HStack {
484 Text(comment.author)
485 .font(.gbSans(.caption).weight(.semibold))
486 Text(comment.createdAt, format: .relative(presentation: .named))
487 .font(.gbSans(.caption))
488 .foregroundStyle(.tertiary)
489 }
490 FormattedBodyView(text: comment.body, format: comment.bodyFormat)
491 .font(.gbSans(.subheadline))
492 }
493 .padding(.vertical, 2)
494 }
495
496 HStack {
497 TextField("Comment", text: $commentText, axis: .vertical)
498 .lineLimit(1...5)
499 Picker("Format", selection: $commentFormat) {
500 Text("Markdown").tag("md")
501 Text("Org").tag("org")
502 }
503 .pickerStyle(.segmented)
504 .labelsHidden()
505 .fixedSize()
506 .accessibilityIdentifier("mr-comment-format")
507 Button {
508 let text = commentText
509 commentText = ""
510 Task { await model.comment(text, format: commentFormat) }
511 } label: {
512 Image(systemName: "arrow.up.circle.fill")
513 }
514 .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
515 || model.working)
516 }
517 }
518 }
519
520 @ToolbarContentBuilder
521 private var toolbar: some ToolbarContent {
522 ToolbarItem(placement: .topBarTrailing) {
523 if let mr = model.state.value, mr.isOpen {
524 Menu {
525 Button {
526 draftTitle = mr.title
527 draftBody = mr.body ?? ""
528 draftFormat = mr.bodyFormat ?? "md"
529 editing = true
530 } label: {
531 Label("Edit", systemImage: "pencil")
532 }
533 Divider()
534 Button {
535 Task { await model.review(.approve) }
536 } label: {
537 Label("Approve", systemImage: "checkmark.circle")
538 }
539 Button {
540 Task { await model.review(.requestChanges) }
541 } label: {
542 Label("Request Changes", systemImage: "exclamationmark.circle")
543 }
544 Divider()
545 Button {
546 Task { await model.setDraft(!mr.draft) }
547 } label: {
548 Label(mr.draft ? "Mark as ready" : "Mark as draft",
549 systemImage: mr.draft ? "checkmark.circle" : "pencil.circle")
550 }
551 Button {
552 retargeting = true
553 } label: {
554 Label("Retarget", systemImage: "arrow.triangle.branch")
555 }
556 Divider()
557 Button {
558 confirmingMerge = true
559 } label: {
560 Label(mr.draft ? "Merge (mark as ready first)" : "Merge",
561 systemImage: "arrow.triangle.merge")
562 }
563 .disabled(mr.draft)
564 Button(role: .destructive) {
565 confirmingClose = true
566 } label: {
567 Label("Close", systemImage: "xmark.circle")
568 }
569 } label: {
570 if model.working {
571 ProgressView()
572 } else {
573 Image(systemName: "ellipsis.circle")
574 }
575 }
576 .disabled(model.working)
577 .accessibilityIdentifier("mr-actions-menu")
578 }
579 }
580 }
581
582 private func checkIcon(_ state: String) -> String {
583 switch state {
584 case "success": "checkmark.circle.fill"
585 case "failure", "error": "xmark.circle.fill"
586 case "pending", "running": "circle.dotted"
587 default: "questionmark.circle"
588 }
589 }
590
591 private func checkColor(_ state: String) -> Color {
592 switch state {
593 case "success": .gbOK
594 case "failure", "error": .gbBad
595 case "pending", "running": .gbWarn
596 default: .secondary
597 }
598 }
599}
600
601/// One review thread: anchor, comments, reply, resolve.
602struct ReviewThreadView: View {
603
604 let thread: ReviewThread
605 let model: MRDetailViewModel
606 @State private var replyText = ""
607
608 var body: some View {
609 VStack(alignment: .leading, spacing: 6) {
610 HStack(spacing: 6) {
611 Image(systemName: thread.isResolved
612 ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right")
613 .font(.gbSans(.caption))
614 .foregroundStyle(thread.isResolved ? Color.gbOK : Color.gbWarn)
615 Text("\(thread.path):\(thread.line)")
616 .font(.gbMono(.caption))
617 .lineLimit(1)
618 if thread.stale {
619 GBChip("stale", .gbWarn)
620 }
621 Spacer()
622 Button(thread.isResolved ? "Reopen" : "Resolve") {
623 Task { await model.setResolved(thread, !thread.isResolved) }
624 }
625 .font(.gbSans(.caption))
626 .buttonStyle(.bordered)
627 .disabled(model.working)
628 }
629 ForEach(thread.comments) { comment in
630 VStack(alignment: .leading, spacing: 2) {
631 HStack {
632 Text(comment.author)
633 .font(.gbSans(.caption).weight(.semibold))
634 Text(comment.createdAt, format: .relative(presentation: .named))
635 .font(.gbSans(.caption2))
636 .foregroundStyle(.tertiary)
637 }
638 Text(comment.body)
639 .font(.gbSans(.subheadline))
640 }
641 }
642 if !thread.isResolved {
643 HStack {
644 TextField("Reply", text: $replyText, axis: .vertical)
645 .font(.gbSans(.subheadline))
646 .lineLimit(1...4)
647 Button {
648 let text = replyText
649 replyText = ""
650 Task { await model.reply(to: thread, text) }
651 } label: {
652 Image(systemName: "arrow.up.circle.fill")
653 }
654 .disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
655 || model.working)
656 }
657 }
658 }
659 .padding(.vertical, 4)
660 }
661}
662
663/// A single-purpose branch picker for retarget. `model.branches` is
664/// `nil` while loading and `model.branchesError` distinguishes a failed
665/// `repo refs` fetch from a repository that genuinely has no other
666/// branches — an empty list must not read as a failed load.
667private struct RetargetSheet: View {
668
669 let model: MRDetailViewModel
670 let onPick: (String) -> Void
671
672 @Environment(\.dismiss) private var dismiss
673
674 var body: some View {
675 NavigationStack {
676 Group {
677 if let error = model.branchesError {
678 ContentUnavailableView {
679 Label("Could not load branches", systemImage: "wifi.exclamationmark")
680 } description: {
681 Text(error)
682 }
683 } else if let branches = model.branches {
684 if branches.isEmpty {
685 ContentUnavailableView {
686 Label("No other branches", systemImage: "arrow.triangle.branch")
687 } description: {
688 Text("This repository has no other branches to retarget to.")
689 }
690 } else {
691 List(branches, id: \.self) { branch in
692 Button(branch) { onPick(branch) }
693 }
694 }
695 } else {
696 ProgressView()
697 }
698 }
699 .navigationTitle("Retarget !\(model.number)")
700 .navigationBarTitleDisplayMode(.inline)
701 .toolbar {
702 ToolbarItem(placement: .cancellationAction) {
703 Button("Cancel") { dismiss() }
704 }
705 }
706 .task { await model.loadBranches() }
707 }
708 }
709}