Files
byre/mac/Sources/Byre/MergeDialog.swift
T

359 lines
13 KiB
Swift

import ByreAPI
import SwiftUI
typealias Participant = Components.Schemas.Participant
@MainActor
private var mergeDrafts: [String: String] = [:]
private let maxTitle = 72
typealias Leave = @MainActor (@escaping @MainActor () -> Void) -> Void
struct MergeWarning {
let text: String
var open: (@MainActor () -> Void)?
var detail = ""
}
@MainActor
@Observable
final class MergeModel {
let taskId: String
let target: String
let parentTitle: String?
let coAuthors: [Participant]
let original: String
var title: String
var editing = false
var toParent: Bool
var reviewItems: [MergeWarning] = []
@ObservationIgnored var running = false
@ObservationIgnored var leave: Leave = { _ in }
@ObservationIgnored private var loads = 0
init(taskId: String, target: String, parentTitle: String?, coAuthors: [Participant], title: String) {
self.taskId = taskId
self.target = target
self.parentTitle = parentTitle
self.coAuthors = coAuthors
original = title
self.title = title
toParent = parentTitle != nil
}
var valid: Bool {
toParent || !trimmed(title).isEmpty
}
var sentTitle: String {
editing ? title : original
}
func load(_ store: Store) async {
loads += 1
let started = loads
var items: [MergeWarning]
var isRunning = false
do {
(items, isRunning) = try await reviewWarnings(taskId, store, leave)
} catch {
items = [MergeWarning(text: "Review status unavailable", detail: errorText(error))]
}
if started == loads {
reviewItems = items
running = isRunning
}
}
func stop() {
loads += 1
}
}
@MainActor
func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays) async {
guard let task = store.findTask(taskId), task.status == .active else {
return
}
if store.taskBusy(taskId) {
overlays.showAlert("Agent is mid-turn; merge when it's idle")
return
}
if task.mergeQueuedAt != nil {
overlays.showAlert("Merge already held behind another merge in this project")
return
}
if task.mergingIntoParent {
overlays.showAlert("Already waiting for the parent task's agent to merge this subtask")
return
}
let target = store.projects.first { $0.id == task.projectId }?.defaultBranch ?? "the default branch"
let parent = task.parentTaskId.flatMap { store.findTask($0) }
let coAuthors: [Participant]
do {
let client = store.api.client
async let unmerged = client.getUnmerged(path: .init(taskId: taskId)).ok.body.json
async let people = client.getCoAuthors(path: .init(taskId: taskId)).ok.body.json
let (u, p) = try await (unmerged, people)
if u.commits == 0 && u.files == 0 {
overlays.showAlert("Nothing to merge: no commits or uncommitted files differ from \(target)")
return
}
coAuthors = p
} catch {
overlays.showAlert(errorText(error))
return
}
let title = mergeDrafts[taskId] ?? task.mergeTitle
mergeDrafts[taskId] = nil
let model = MergeModel(taskId: taskId, target: target, parentTitle: parent?.status == .active ? parent?.title : nil, coAuthors: coAuthors, title: title)
model.leave = { [weak model] go in
if let model {
mergeDrafts[taskId] = model.sentTitle
}
overlays.answerModal("cancel")
go()
}
let off = store.events.add { ev in
if ev.taskId == taskId && (ev.kind == "snapshot.created" || isReviewEvent(ev)) {
Task {
await model.load(store)
}
}
}
defer {
model.stop()
off()
}
await model.load(store)
let content = MergeContent(model: model)
let merge = { (afterReview: Bool) in
let body: Operations.MergeTask.Input.Body.JsonPayload = model.toParent
? .init(target: .parent, afterReview: afterReview ? true : nil)
: .init(mergeTitle: model.sentTitle, afterReview: afterReview ? true : nil)
do {
_ = try await store.api.client.mergeTask(path: .init(taskId: taskId), body: .json(body)).accepted
} catch {
overlays.showAlert(errorText(error))
}
}
if !model.running {
if await overlays.showModal(content, [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge", value: "ok", primary: true)], inPane: true, canSubmit: { model.valid }) == "ok" {
await merge(false)
}
return
}
let choice = await overlays.showModal(content, [
ModalButton(label: "Cancel", value: "cancel"),
ModalButton(label: "Merge now", value: "now"),
ModalButton(label: "After review", value: "after", primary: true, help: "Merge once the running review finishes with no findings"),
], inPane: true, canSubmit: { model.valid })
if choice == "now" || choice == "after" {
await merge(choice == "after")
}
}
@MainActor
private func reviewWarnings(_ taskId: String, _ store: Store, _ leave: @escaping Leave) async throws -> ([MergeWarning], Bool) {
let client = store.api.client
async let snapsCall = client.listSnapshots(path: .init(taskId: taskId)).ok.body.json
async let reviewsCall = client.listReviews(path: .init(taskId: taskId)).ok.body.json
let (snaps, reviews) = try await (snapsCall, reviewsCall)
guard let latest = snaps.last(where: { $0.trigger != .answer }) else {
return ([MergeWarning(text: "These changes haven't been reviewed")], false)
}
var items: [MergeWarning] = []
let open: @MainActor () -> Void = {
leave {
store.focusTask(taskId, .reviews)
store.pickReview(latest.n)
}
}
let r = reviews.first { $0.snapshotN == latest.n }
if let r {
let found = unignored(r)
switch r.state {
case .running:
items.append(MergeWarning(text: "Review of the latest snapshot is still running", open: open))
case .failed:
items.append(MergeWarning(text: "Review of the latest snapshot failed", open: open))
default:
if !found.isEmpty {
items.append(MergeWarning(text: "Review of the latest snapshot found \(severityCounts(found))", open: open))
}
}
} else {
items.append(MergeWarning(text: "The latest snapshot hasn't been reviewed", open: open))
}
let changes = try await client.getTaskChanges(path: .init(taskId: taskId), query: .init(since: latest.commitSha)).ok.body.json
if !changes.files.isEmpty {
items.append(MergeWarning(text: "Changes since the last snapshot haven't been reviewed"))
}
return (items, r?.state == .running)
}
private func unignored(_ r: Review) -> [JSONDict] {
r.state == .done ? findings(r).enumerated().filter { r.findingStates.additionalProperties[String($0.offset)] != .ignored }.map(\.element) : []
}
private func coAuthorsLabel(_ coAuthors: [Participant]) -> String {
let missing = coAuthors.filter { $0.gitName.isEmpty || $0.gitEmail.isEmpty }.count
return "\(coAuthors.count) co-author\(coAuthors.count == 1 ? "" : "s")\(missing > 0 ? " (\(missing) without git identity)" : "")"
}
private func mergeTrailers(_ coAuthors: [Participant]) -> String {
var lines: [String] = []
let credited = coAuthors.filter { !$0.gitName.isEmpty && !$0.gitEmail.isEmpty }
let missing = coAuthors.filter { $0.gitName.isEmpty || $0.gitEmail.isEmpty }
if !credited.isEmpty {
lines.append("Co-authored-by:")
lines += credited.map { " \($0.gitName) <\($0.gitEmail)>" }
}
if !missing.isEmpty {
if !lines.isEmpty {
lines.append("")
}
lines.append("No git identity, so no trailer:")
lines += missing.map { " \($0.displayName) (\($0.login))" }
}
return lines.joined(separator: "\n")
}
private struct MergeContent: View {
@Bindable var model: MergeModel
@Environment(Store.self) private var store
@FocusState private var focused: Bool
var body: some View {
let questions = store.inbox.filter { $0.taskId == model.taskId }.count
var warnings = model.reviewItems
if questions > 0 {
let taskId = model.taskId
let leave = model.leave
warnings.append(MergeWarning(text: questions == 1 ? "The agent's open question will be dismissed" : "The agent's \(questions) open questions will be dismissed", open: {
leave {
store.focusTask(taskId, .questions)
}
}))
}
return VStack(alignment: .leading, spacing: 0) {
if let parentTitle = model.parentTitle {
VStack(alignment: .leading, spacing: 8) {
option(true, "Into \(parentTitle)", "Its agent merges this subtask's branch into the parent's branch; the subtask closes once it has.")
option(false, "Into \(model.target)", "Squashed into one commit on the default branch and pushed, like any task.")
}
.padding(.bottom, 16)
}
if !model.toParent {
Text("Merge into \(model.target) as:")
.foregroundStyle(Palette.muted)
.padding(.bottom, 4)
titleRow
.padding(.bottom, 16)
}
if !warnings.isEmpty {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(warnings.enumerated()), id: \.offset) { _, w in
warningRow(w)
}
}
.foregroundStyle(Palette.warn)
.padding(.bottom, 16)
}
if !model.toParent && !model.coAuthors.isEmpty {
DetailsBox(boxed: false) {
Text(coAuthorsLabel(model.coAuthors))
.foregroundStyle(Palette.muted)
} content: {
ModalText(text: mergeTrailers(model.coAuthors))
.padding(.top, 8)
}
.padding(.bottom, 16)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private var titleRow: some View {
if model.editing {
TextField("", text: Binding(get: { model.title }, set: { model.title = String($0.prefix(maxTitle)) }))
.modifier(FieldStyle(focused: focused))
.focused($focused)
.accessibilityLabel("Commit title")
.onAppear {
focused = true
}
} else {
HStack(spacing: 8) {
Text(model.title)
.font(Fonts.font(13, weight: 700))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
Button("✎") {
model.editing = true
}
.buttonStyle(.plain)
.foregroundStyle(Palette.muted)
.padding(.horizontal, 6)
.help("Edit the commit title")
.accessibilityLabel("Edit the commit title")
}
}
}
private func option(_ value: Bool, _ label: String, _ hint: String) -> some View {
Button {
model.toParent = value
} label: {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: model.toParent == value ? "largecircle.fill.circle" : "circle")
.foregroundStyle(model.toParent == value ? Palette.accent : Palette.muted)
VStack(alignment: .leading, spacing: 0) {
Text(label)
Text(hint)
.font(Fonts.font(12))
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@ViewBuilder
private func warningRow(_ w: MergeWarning) -> some View {
if let open = w.open {
HStack(alignment: .firstTextBaseline, spacing: 0) {
Text("⚠ ")
Button(action: open) {
Text(w.text)
.underline()
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
.buttonStyle(.plain)
}
} else {
Text("⚠ \(w.text)")
.fixedSize(horizontal: false, vertical: true)
.help(w.detail)
}
}
}
@MainActor
func confirmDiscard(_ task: ByreTask, _ store: Store, _ overlays: Overlays, anchor: CGRect?) async {
if !(await overlays.showConfirm(["Discard \"\(task.title)\"?"], action: "Discard", danger: true, anchor: anchor)) {
return
}
do {
_ = try await store.api.client.discardTask(path: .init(taskId: task.id)).accepted
try await store.setFocus(taskId: .some(nil))
} catch {
overlays.showAlert(errorText(error))
}
}