Open merge dialog instantly from pushed merge facts

This commit is contained in:
Ian Gulliver
2026-10-21 11:48:21 -07:00
parent 4bb6f1fa11
commit 890fb6b691
15 changed files with 736 additions and 678 deletions
+50 -117
View File
@@ -2,6 +2,7 @@ import ByreAPI
import SwiftUI
typealias Participant = Components.Schemas.Participant
typealias MergeFacts = Components.Schemas.MergeFacts
@MainActor
private var mergeDrafts: [String: String] = [:]
@@ -23,22 +24,17 @@ final class MergeModel {
let target: String
let keepOpen: Bool
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, keepOpen: Bool, parentTitle: String?, coAuthors: [Participant], title: String) {
init(taskId: String, target: String, keepOpen: Bool, parentTitle: String?, title: String) {
self.taskId = taskId
self.target = target
self.keepOpen = keepOpen
self.parentTitle = parentTitle
self.coAuthors = coAuthors
original = title
self.title = title
toParent = parentTitle != nil
@@ -51,26 +47,6 @@ final class MergeModel {
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
@@ -91,25 +67,14 @@ func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays, keepOp
return
}
let target = store.projects.first { $0.id == task.projectId }?.defaultBranch ?? "the default branch"
let parent = task.workflowStage == nil ? task.parentTaskId.flatMap { store.findTask($0) } : nil
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))
if task.mergeFacts.unmergedCommits == 0 && task.mergeFacts.unmergedFiles == 0 {
overlays.showAlert("Nothing to merge: no commits or uncommitted files differ from \(target)")
return
}
let parent = task.workflowStage == nil ? task.parentTaskId.flatMap { store.findTask($0) } : nil
let title = mergeDrafts[taskId] ?? task.mergeTitle
mergeDrafts[taskId] = nil
let model = MergeModel(taskId: taskId, target: target, keepOpen: keepOpen, parentTitle: !keepOpen && parent?.status == .active ? parent?.name : nil, coAuthors: coAuthors, title: title)
let model = MergeModel(taskId: taskId, target: target, keepOpen: keepOpen, parentTitle: !keepOpen && parent?.status == .active ? parent?.name : nil, title: title)
model.leave = { [weak model] go in
if let model {
mergeDrafts[taskId] = model.sentTitle
@@ -117,92 +82,58 @@ func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays, keepOp
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, keepOpen: keepOpen ? true : nil)
do {
_ = try await store.api.client.mergeTask(path: .init(taskId: taskId), body: .json(body)).accepted
} catch {
overlays.showAlert(errorText(error))
}
}
if keepOpen {
if await overlays.showModal(content, [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge & continue", value: "ok", primary: true)], inPane: true, canSubmit: { model.valid }) == "ok" {
await merge(false)
}
let choice = await overlays.showModal(content, keepOpen
? [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge & continue", value: "now", primary: true)]
: store.findTask(taskId)?.mergeFacts.reviewRunning == true
? [
ModalButton(label: "Cancel", value: "cancel"),
ModalButton(label: "Merge & complete now", value: "now"),
ModalButton(label: "Merge & complete after review", value: "after", primary: true, help: "Merge once the running review finishes with no findings"),
]
: [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge & complete", value: "now", primary: true)],
inPane: true, canSubmit: { model.valid })
if choice != "now" && choice != "after" {
return
}
if !model.running {
if await overlays.showModal(content, [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge & complete", 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 & complete now", value: "now"),
ModalButton(label: "Merge & complete 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")
let afterReview = choice == "after"
let body: Operations.MergeTask.Input.Body.JsonPayload = model.toParent
? .init(target: .parent, afterReview: afterReview ? true : nil)
: .init(mergeTitle: model.sentTitle, afterReview: afterReview ? true : nil, keepOpen: keepOpen ? true : nil)
do {
_ = try await store.api.client.mergeTask(path: .init(taskId: taskId), body: .json(body)).accepted
} catch {
overlays.showAlert(errorText(error))
}
}
@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, .askHuman].contains($0.trigger) }) else {
return ([MergeWarning(text: "These changes haven't been reviewed")], false)
private func reviewWarnings(_ taskId: String, _ f: MergeFacts, _ store: Store, _ leave: @escaping Leave) -> [MergeWarning] {
if f.reviewSnapshot == 0 {
return [MergeWarning(text: "These changes haven't been reviewed")]
}
var items: [MergeWarning] = []
let open: @MainActor () -> Void = {
leave {
store.jumpTo(taskId, .review(latest.n))
store.jumpTo(taskId, .review(f.reviewSnapshot))
}
}
let rs = reviews.filter { $0.snapshotN == latest.n }
for r in rs {
let found = unignored(r)
switch r.state {
case .running:
items.append(MergeWarning(text: "The \(r.stage) review of the latest snapshot is still running", open: open))
case .failed:
items.append(MergeWarning(text: "The \(r.stage) review of the latest snapshot failed", open: open))
default:
if !found.isEmpty {
items.append(MergeWarning(text: "The \(r.stage) review of the latest snapshot found \(severityCounts(found))", open: open))
}
}
}
if rs.isEmpty {
if f.reviewCount == 0 {
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 {
if f.reviewRunning {
items.append(MergeWarning(text: "The review of the latest snapshot is still running", open: open))
}
if f.reviewFailed {
items.append(MergeWarning(text: "The review of the latest snapshot failed", open: open))
}
if f.reviewFindings > 0 {
items.append(MergeWarning(text: "The review of the latest snapshot has \(f.reviewFindings) open finding\(f.reviewFindings == 1 ? "" : "s")", open: open))
}
if f.unreviewedChanges {
items.append(MergeWarning(text: "Changes since the last snapshot haven't been reviewed"))
}
return (items, rs.contains { $0.state == .running })
}
private func unignored(_ r: Review) -> [JSONDict] {
r.state == .done ? findings(r).enumerated().filter { r.findingStates.additionalProperties[String($0.offset)] != .ignored }.map(\.element) : []
return items
}
private func coAuthorsLabel(_ coAuthors: [Participant]) -> String {
@@ -234,11 +165,13 @@ private struct MergeContent: View {
@FocusState private var focused: Bool
var body: some View {
let questions = store.inbox.filter { $0.taskId == model.taskId }.count
var warnings = model.reviewItems
let taskId = model.taskId
let leave = model.leave
let facts = store.findTask(taskId)?.mergeFacts
let coAuthors = facts?.participants.filter { $0.login != store.me?.login } ?? []
let questions = store.inbox.filter { $0.taskId == taskId }.count
var warnings = facts.map { reviewWarnings(taskId, $0, store, leave) } ?? []
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)
@@ -269,12 +202,12 @@ private struct MergeContent: View {
.foregroundStyle(Palette.warn)
.padding(.bottom, 16)
}
if !model.toParent && !model.coAuthors.isEmpty {
if !model.toParent && !coAuthors.isEmpty {
DetailsBox(boxed: false) {
Text(coAuthorsLabel(model.coAuthors))
Text(coAuthorsLabel(coAuthors))
.foregroundStyle(Palette.muted)
} content: {
ModalText(text: mergeTrailers(model.coAuthors))
ModalText(text: mergeTrailers(coAuthors))
.padding(.top, 8)
}
.padding(.bottom, 16)