2026-10-17 09:33:27 -07:00
|
|
|
|
import ByreAPI
|
2026-10-17 07:26:18 -07:00
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
|
|
typealias FindingState = Components.Schemas.FindingState
|
2026-10-19 09:14:21 -07:00
|
|
|
|
typealias FindingResponse = Components.Schemas.FindingResponse
|
2026-10-17 07:26:18 -07:00
|
|
|
|
|
|
|
|
|
|
private let findingHead = Re(#"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@"#)
|
|
|
|
|
|
|
|
|
|
|
|
@MainActor
|
2026-10-17 07:43:33 -07:00
|
|
|
|
private enum SnapshotPatches {
|
2026-10-17 07:26:18 -07:00
|
|
|
|
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? {
|
2026-10-18 20:58:56 -07:00
|
|
|
|
let key = "\(api.origin.absoluteString)/\(taskId)/\(n)/\(path)"
|
2026-10-17 07:26:18 -07:00
|
|
|
|
if let cached = patches[key] {
|
|
|
|
|
|
return try await cached.value
|
|
|
|
|
|
}
|
|
|
|
|
|
let fetched = Task<String?, any Error> {
|
2026-10-18 20:58:56 -07:00
|
|
|
|
let ck = "\(api.origin.absoluteString)/\(taskId)/\(n)"
|
2026-10-17 07:26:18 -07:00
|
|
|
|
let pending = changes[ck] ?? Task {
|
2026-10-17 07:43:33 -07:00
|
|
|
|
try await api.client.getTaskChanges(path: .init(taskId: taskId), query: .init(snapshot: n)).ok.body.json
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
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 }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 09:14:21 -07:00
|
|
|
|
func findingResponse(_ r: Review, _ index: Int) -> FindingResponse? {
|
|
|
|
|
|
r.findingResponses.additionalProperties[String(index)]
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 09:14:21 -07:00
|
|
|
|
func findingOpen(_ r: Review, _ index: Int) -> Bool {
|
|
|
|
|
|
r.findingStates.additionalProperties[String(index)] == nil && findingResponse(r, index) == nil
|
|
|
|
|
|
}
|
2026-10-17 07:26:18 -07:00
|
|
|
|
|
2026-10-19 09:14:21 -07:00
|
|
|
|
func findingWhere(_ f: JSONDict) -> String {
|
|
|
|
|
|
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
|
|
|
|
|
|
return path.isEmpty ? "" : "\(path)\(line > 0 ? ":\(line)\(end > line ? "–\(end)" : "")" : "")"
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 09:14:21 -07:00
|
|
|
|
struct FindingRow: View {
|
2026-10-17 07:26:18 -07:00
|
|
|
|
let review: Review
|
|
|
|
|
|
let index: Int
|
|
|
|
|
|
let taskId: String?
|
|
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
2026-10-19 09:14:21 -07:00
|
|
|
|
let f = findings(review)[index]
|
2026-10-17 07:26:18 -07:00
|
|
|
|
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
|
2026-10-19 09:14:21 -07:00
|
|
|
|
let whereText = findingWhere(f)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
let md = MDContext(taskId: taskId)
|
2026-10-19 09:14:21 -07:00
|
|
|
|
let response = findingResponse(review, index)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
|
|
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
2026-10-19 09:14:21 -07:00
|
|
|
|
Text("\(index + 1).")
|
|
|
|
|
|
.font(Fonts.font(11, weight: 700))
|
|
|
|
|
|
.foregroundStyle(Palette.muted)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
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 {
|
2026-10-19 09:14:21 -07:00
|
|
|
|
FindingActions(taskId: taskId, review: review, index: index)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-19 09:14:21 -07:00
|
|
|
|
if let response {
|
|
|
|
|
|
Text("agent \(response.response.rawValue): \(response.reason)")
|
|
|
|
|
|
.font(Fonts.font(11))
|
|
|
|
|
|
.foregroundStyle(Palette.muted)
|
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
|
}
|
|
|
|
|
|
Group {
|
|
|
|
|
|
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)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
2026-10-19 09:14:21 -07:00
|
|
|
|
.opacity(response == nil ? 1 : 0.5)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
2026-10-19 09:14:21 -07:00
|
|
|
|
.padding(.leading, 10)
|
|
|
|
|
|
.overlay(alignment: .leading) {
|
|
|
|
|
|
Rectangle().fill(Palette.border).frame(width: 1)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 09:14:21 -07:00
|
|
|
|
struct ReviewImages: View {
|
2026-10-17 07:26:18 -07:00
|
|
|
|
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)") {
|
2026-10-17 10:54:06 -07:00
|
|
|
|
failure = nil
|
2026-10-17 07:26:18 -07:00
|
|
|
|
do {
|
2026-10-17 07:43:33 -07:00
|
|
|
|
let patch = try await SnapshotPatches.patch(store.api, taskId, n, path)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 09:14:21 -07:00
|
|
|
|
struct FindingActions: View {
|
2026-10-17 07:26:18 -07:00
|
|
|
|
let taskId: String
|
|
|
|
|
|
let review: Review
|
|
|
|
|
|
let index: Int
|
|
|
|
|
|
@Environment(Store.self) private var store
|
|
|
|
|
|
@Environment(Overlays.self) private var overlays
|
|
|
|
|
|
@ViewState private var busy = false
|
|
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
2026-10-19 09:14:21 -07:00
|
|
|
|
let response = findingResponse(review, index)?.response
|
|
|
|
|
|
let state = review.findingStates.additionalProperties[String(index)]
|
|
|
|
|
|
Group {
|
|
|
|
|
|
if let state {
|
|
|
|
|
|
Text(state == .fixed ? "fix sent" : "ignored")
|
|
|
|
|
|
.font(Fonts.font(11))
|
|
|
|
|
|
.foregroundStyle(Palette.muted)
|
|
|
|
|
|
} else if response != .fixing {
|
|
|
|
|
|
HStack(spacing: 6) {
|
|
|
|
|
|
Button("fix") {
|
|
|
|
|
|
fix()
|
|
|
|
|
|
}
|
|
|
|
|
|
.buttonStyle(SmallLinkButtonStyle())
|
|
|
|
|
|
.help(response == .ignoring ? "Overrule the agent: send this finding to it to fix" : "Send this finding to the task's agent to fix")
|
|
|
|
|
|
if response == nil {
|
|
|
|
|
|
Button("ignore") {
|
|
|
|
|
|
ignore()
|
|
|
|
|
|
}
|
|
|
|
|
|
.buttonStyle(SmallLinkButtonStyle())
|
|
|
|
|
|
.help("Mark this finding done without fixing it; Merge won't warn about it")
|
|
|
|
|
|
}
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
2026-10-19 09:14:21 -07:00
|
|
|
|
.disabled(busy)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
2026-10-19 09:14:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
.onChange(of: state) {
|
|
|
|
|
|
busy = false
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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() {
|
2026-10-19 09:14:21 -07:00
|
|
|
|
let f = findings(review)[index]
|
|
|
|
|
|
let whereText = findingWhere(f)
|
2026-10-20 18:52:04 -07:00
|
|
|
|
let text = "Fix finding \(index + 1) from reviewer \"\(review.stage)\" of snapshot #\(review.snapshotN):\n\n**[\(jsString(f["severity"] ?? ""))] \(jsString(f["title"] ?? ""))**\(whereText.isEmpty ? "" : " (`\(whereText)`)")\n\n\(jsString(f["detail"] ?? ""))"
|
2026-10-17 07:26:18 -07:00
|
|
|
|
busy = true
|
|
|
|
|
|
Task {
|
|
|
|
|
|
do {
|
2026-10-18 07:30:00 -07:00
|
|
|
|
try await store.messageTask(taskId, text)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
} catch {
|
|
|
|
|
|
busy = false
|
|
|
|
|
|
overlays.showAlert(errorText(error))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
do {
|
|
|
|
|
|
try await mark(.fixed)
|
|
|
|
|
|
} catch {
|
2026-10-19 09:14:21 -07:00
|
|
|
|
busy = false
|
2026-10-17 07:26:18 -07:00
|
|
|
|
overlays.showAlert("fix sent, but not recorded: \(errorText(error))")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func ignore() {
|
|
|
|
|
|
busy = true
|
|
|
|
|
|
Task {
|
|
|
|
|
|
do {
|
|
|
|
|
|
try await mark(.ignored)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
busy = false
|
|
|
|
|
|
overlays.showAlert(errorText(error))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|