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

448 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
typealias FindingState = Components.Schemas.FindingState
private let findingHead = Re(#"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@"#)
private func stateText(_ r: Review) -> String {
switch r.state {
case .running: "reviewing on \(r.vendor) (\(r.model))…"
case .done: reviewCounts(r)
case .failed: "failed"
case .skipped: "skipped: no changes"
}
}
@MainActor
private enum StepPatches {
static var changes: [String: Task<TaskChanges, any Error>] = [:]
static var patches: [String: Task<String?, any Error>] = [:]
static func patch(_ api: API, _ taskId: String, _ n: Int, _ path: String) async throws -> String? {
let key = "\(taskId)/\(n)/\(path)"
if let cached = patches[key] {
return try await cached.value
}
let fetched = Task<String?, any Error> {
let ck = "\(taskId)/\(n)"
let pending = changes[ck] ?? Task {
try await api.client.getTaskChanges(path: .init(taskId: taskId), query: .init(snapshot: n, step: true)).ok.body.json
}
changes[ck] = pending
let c: TaskChanges
do {
c = try await pending.value
} catch {
changes[ck] = nil
throw error
}
guard let f = c.files.first(where: { $0.path == path }), !f.binary else {
return nil
}
return try await api.client.getTaskDiff(path: .init(taskId: taskId), query: .init(base: c.base, tree: c.tree, path: f.path, oldPath: f.oldPath)).ok.body.json.patch
}
patches[key] = fetched
do {
return try await fetched.value
} catch {
patches[key] = nil
throw error
}
}
}
private func linesAround(_ patch: String, _ line: Int, _ end: Int) -> [(kind: PatchLine, text: String, at: Int)] {
var lines: [(kind: PatchLine, text: String, at: Int)] = []
var n = 0
var inHunk = false
for l in patch.components(separatedBy: "\n") {
if let m = findingHead.exec(l) {
n = Int(m[1]!) ?? 0
inHunk = true
continue
}
if !inHunk {
continue
}
if l.hasPrefix("+") {
lines.append((.add, l, n))
n += 1
} else if l.hasPrefix("-") {
lines.append((.del, l, n))
} else if l.hasPrefix(" ") {
lines.append((.ctx, l, n))
n += 1
}
}
return lines.filter { $0.at >= line - 3 && $0.at <= end + 3 }
}
struct ReviewsView: View {
@Environment(Store.self) private var store
@ViewState private var reviews: [Review]?
@ViewState private var error = ""
@ViewState private var target = 0
var body: some View {
VStack(spacing: 0) {
content
}
.frame(maxHeight: .infinity, alignment: .top)
.task(id: store.selectedTaskId) {
target += 1
reviews = nil
error = ""
await load()
let off = store.selectedFeed?.appended.add { events in
if events.contains(where: isReviewEvent) {
Task {
await load()
}
}
}
await untilCancelled()
off?()
target += 1
}
}
private func load() async {
guard let taskId = store.selectedTaskId else {
return
}
target += 1
let started = target
do {
let fresh = try await store.api.client.listReviews(path: .init(taskId: taskId)).ok.body.json
if started == target {
reviews = fresh
error = ""
}
} catch {
if started == target {
reviews = nil
self.error = errorText(error)
}
}
}
@ViewBuilder
private var content: some View {
if let taskId = store.selectedTaskId {
if !error.isEmpty {
EmptyText(text: error, error: true)
} else if let reviews {
if let lastReview = reviews.last {
let n = store.reviewed ?? lastReview.snapshotN
let shown = reviews.last { $0.snapshotN == n }
let ns = Array(Set([n] + reviews.map(\.snapshotN))).sorted(by: >)
let next = ns.filter { $0 > n }.last
let prev = ns.first { $0 < n }
HStack(spacing: 8) {
Picker("", selection: Binding(get: { n }, set: { store.pickReview($0) })) {
ForEach(ns, id: \.self) { s in
Text("snapshot #\(s)").tag(s)
}
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.help("Snapshot whose review to show")
step(prev ?? n - 1, "← #\(prev ?? n - 1)", ns)
step(next ?? n + 1, "#\(next ?? n + 1) →", ns)
}
.modifier(HeaderBar())
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if let shown {
ReviewCard(review: shown, taskId: taskId)
} else {
Muted(text: "No review of snapshot #\(n) yet.")
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
}
} else {
EmptyText(text: "No reviews yet. Each new snapshot of the task is reviewed by an agent from the other vendor.")
}
} else {
EmptyText(text: "Loading…")
}
} else {
EmptyText(text: "No task selected.")
}
}
private func step(_ to: Int, _ label: String, _ ns: [Int]) -> some View {
Button(label) {
store.pickReview(to)
}
.buttonStyle(SmallLinkButtonStyle(color: Palette.text))
.disabled(!ns.contains(to))
}
}
struct ReviewCard: View {
let review: Review
let taskId: String?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("snapshot #\(review.snapshotN)")
.font(Fonts.font(11, weight: 700))
.foregroundStyle(Palette.text)
Text(stateText(review))
.foregroundStyle(review.state == .failed ? Palette.warn : Palette.muted)
Spacer(minLength: 0)
Text(ago(review.createdAt))
.help(isoText(review.createdAt))
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
if review.state == .failed {
Text(review.error)
.foregroundStyle(Palette.danger)
.textSelection(.enabled)
}
if review.state == .done {
ReviewResult(review: review, taskId: taskId)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
}
}
private struct ReviewResult: View {
let review: Review
let taskId: String?
var body: some View {
let res = review.result?.value ?? [:]
let fs = findings(review)
let md = MDContext(taskId: taskId)
VStack(alignment: .leading, spacing: 8) {
MarkdownView(blocks: parseMarkdown(jsString(res["summary"] ?? "")), ctx: md)
ReviewImages(value: res["images"] ?? nil)
if !fs.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(fs.enumerated()), id: \.offset) { i, f in
FindingRow(review: review, index: i, finding: f, taskId: taskId)
}
}
}
}
}
}
private struct FindingRow: View {
let review: Review
let index: Int
let finding: JSONDict
let taskId: String?
var body: some View {
let f = finding
let severity = jsString(f["severity"] ?? "")
let path = str(f, "path") ?? ""
let line = max(0, int(f, "line") ?? 0)
let endRaw = int(f, "end_line") ?? 0
let end = endRaw > line ? endRaw : line
let whereText = path.isEmpty ? "" : "\(path)\(line > 0 ? ":\(line)\(end > line ? "–\(end)" : "")" : "")"
let md = MDContext(taskId: taskId)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(severity)
.font(Fonts.font(11, weight: 700))
.foregroundStyle(severity == "critical" || severity == "high" ? Palette.danger : severity == "medium" ? Palette.warn : Palette.muted)
if let category = str(f, "category"), !category.isEmpty {
Text(category).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
Text(jsString(f["title"] ?? ""))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
if !whereText.isEmpty {
Text(whereText).font(Fonts.font(11)).foregroundStyle(Palette.muted).textSelection(.enabled)
}
Spacer(minLength: 0)
if let taskId {
FindingActions(taskId: taskId, review: review, index: index, finding: f, whereText: whereText)
}
}
if let taskId, !path.isEmpty, line > 0 {
FindingDiff(taskId: taskId, n: review.snapshotN, path: path, line: line, end: end)
}
MarkdownView(blocks: parseMarkdown(jsString(f["detail"] ?? "")), ctx: md)
ReviewImages(value: f["images"] ?? nil)
}
.padding(.top, 6)
.overlay(alignment: .top) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
private struct ReviewImages: View {
let value: (any Sendable)?
var body: some View {
let imgs = (value as? [(any Sendable)?] ?? []).map { $0 as? JSONDict ?? [:] }
if !imgs.isEmpty {
FlowLayout(spacing: 10) {
ForEach(Array(imgs.enumerated()), id: \.offset) { _, img in
let blob = jsString(img["blob"] ?? "")
let caption = str(img, "caption") ?? ""
VStack(alignment: .leading, spacing: 2) {
ByreImage(source: .blob(blob), src: "/api/blobs/\(blob)", maxHeight: 360)
if !caption.isEmpty {
Text(caption).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
}
}
}
.padding(.vertical, 8)
}
}
}
private struct FindingDiff: View {
let taskId: String
let n: Int
let path: String
let line: Int
let end: Int
@Environment(Store.self) private var store
@ViewState private var rows: [DiffRow]?
@ViewState private var failure: String?
var body: some View {
Group {
if let failure {
Text("diff not available: \(failure)")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.modifier(DiffBox())
} else if let rows, !rows.isEmpty {
DiffLines(rows: rows)
.modifier(DiffBox())
}
}
.task(id: "\(taskId)/\(n)/\(path)/\(line)/\(end)") {
do {
let patch = try await StepPatches.patch(store.api, taskId, n, path)
let lines = patch.map { linesAround($0, line, end) } ?? []
var out: [DiffRow] = []
for (i, d) in lines.enumerated() {
if i > 0 {
let prev = lines[i - 1]
if d.kind != .del && prev.kind != .del && d.at > prev.at + 1 {
out.append(DiffRow(text: "⋯", kind: .hunk))
}
}
let mark = d.kind != .del && d.at >= line && d.at <= end
out.append(DiffRow(text: d.text, kind: d.kind, lineno: d.kind == .del ? "" : String(d.at), mark: mark))
}
rows = out
} catch {
failure = errorText(error)
}
}
}
}
private struct DiffBox: ViewModifier {
func body(content: Content) -> some View {
content
.background(Palette.bg)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
.padding(.top, 2)
}
}
private struct FindingActions: View {
let taskId: String
let review: Review
let index: Int
let finding: JSONDict
let whereText: String
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var done: FindingState?
@ViewState private var busy = false
var body: some View {
if let s = done ?? review.findingStates.additionalProperties[String(index)] {
Text(s == .fixed ? "fix sent" : "ignored")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
} else {
HStack(spacing: 6) {
Button("fix") {
fix()
}
.buttonStyle(SmallLinkButtonStyle())
.help("Send this finding to the task's agent to fix")
Button("ignore") {
ignore()
}
.buttonStyle(SmallLinkButtonStyle())
.help("Mark this finding done without fixing it; Merge won't warn about it")
}
.disabled(busy)
}
}
private func mark(_ s: FindingState) async throws {
_ = try await store.api.client.setFindingState(path: .init(reviewId: review.id, index: index), body: .json(.init(state: s))).ok
}
private func fix() {
let f = finding
let text = "Fix this finding from the review of snapshot #\(review.snapshotN):\n\n**[\(jsString(f["severity"] ?? ""))] \(jsString(f["title"] ?? ""))**\(whereText.isEmpty ? "" : " (`\(whereText)`)")\n\n\(jsString(f["detail"] ?? ""))"
busy = true
Task {
do {
let sessions = try await store.api.client.listSessions(path: .init(taskId: taskId)).ok.body.json
if let live = sessions.last(where: { $0.state != .completed }) {
_ = try await store.api.client.sendMessage(path: .init(sessionId: live.id), body: .json(.init(text: text))).accepted
} else {
_ = try await store.api.client.createSession(path: .init(taskId: taskId), body: .json(.init(prompt: text))).created
}
} catch {
busy = false
overlays.showAlert(errorText(error))
return
}
done = .fixed
do {
try await mark(.fixed)
} catch {
overlays.showAlert("fix sent, but not recorded: \(errorText(error))")
}
}
}
private func ignore() {
busy = true
Task {
do {
try await mark(.ignored)
done = .ignored
} catch {
busy = false
overlays.showAlert(errorText(error))
}
}
}
}