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}