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: { ![.answer, .fork].contains($0.trigger) }) else { return ([MergeWarning(text: "These changes haven't been reviewed")], false) } var items: [MergeWarning] = [] let open: @MainActor () -> Void = { leave { store.jumpTo(taskId, .review(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.showQuestion(taskId) } })) } 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(alignment: .firstTextBaseline, spacing: 8) { Text(model.title) .font(Fonts.font(13, weight: 700)) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) 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)) } }