Build iOS App / Build .ipa and publish source (push) Failing after 35s
Multi-repo PR inbox, review (approve/request changes/comment), merge with gating toggle, read-only diff viewer, and Gitea Actions check status with one-tap re-run. Ships unsigned via the iMac runner to the SideStore source.
259 lines
8.1 KiB
Swift
259 lines
8.1 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - Conversation
|
||
|
||
struct ConversationTab: View {
|
||
@ObservedObject var vm: PRDetailViewModel
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
if vm.reviews.count > 0 {
|
||
Text("Reviews")
|
||
.font(.headline)
|
||
ForEach(vm.reviews) { review in
|
||
reviewRow(review)
|
||
}
|
||
}
|
||
Text("Comments")
|
||
.font(.headline)
|
||
if vm.comments.isEmpty {
|
||
Text("No comments yet.")
|
||
.font(.callout)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
ForEach(vm.comments) { comment in
|
||
commentRow(comment)
|
||
}
|
||
Text("Inline file comments are not shown — the Gitea API does not expose them.")
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
.padding(.top, 8)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding()
|
||
}
|
||
|
||
private func reviewRow(_ review: PullReview) -> some View {
|
||
HStack(alignment: .top, spacing: 8) {
|
||
Text(icon)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("\(review.user?.login ?? "unknown") \(verb)")
|
||
.font(.subheadline.weight(.semibold))
|
||
if let body = review.body, !body.isEmpty {
|
||
Text(body).font(.callout)
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
private func commentRow(_ comment: IssueComment) -> some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack {
|
||
Text(comment.user?.login ?? "unknown")
|
||
.font(.subheadline.weight(.semibold))
|
||
if let created = comment.createdAt {
|
||
Text(created, style: .relative)
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
if let body = comment.body, !body.isEmpty {
|
||
Text(body).font(.callout)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(8)
|
||
.background(Color(.secondarySystemBackground))
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
|
||
private var icon: String {
|
||
switch vm.reviewState {
|
||
case .approved: return "✅"
|
||
case .changesRequested: return "🔴"
|
||
default: return "💬"
|
||
}
|
||
}
|
||
private var verb: String {
|
||
switch vm.reviewState {
|
||
case .approved: return "approved"
|
||
case .changesRequested: return "requested changes"
|
||
default: return "reviewed"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Files
|
||
|
||
struct FilesTab: View {
|
||
@ObservedObject var vm: PRDetailViewModel
|
||
|
||
var body: some View {
|
||
if vm.files.isEmpty {
|
||
Text("No changed files.")
|
||
.font(.callout)
|
||
.foregroundColor(.secondary)
|
||
.frame(maxWidth: .infinity)
|
||
.padding()
|
||
} else {
|
||
LazyVStack(spacing: 0) {
|
||
ForEach(vm.files) { file in
|
||
NavigationLink(value: file) {
|
||
fileRow(file)
|
||
}
|
||
Divider()
|
||
}
|
||
}
|
||
.navigationDestination(for: ChangedFile.self) { file in
|
||
DiffView(file: file)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func fileRow(_ file: ChangedFile) -> some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack {
|
||
Text(statusIcon(file))
|
||
Text(file.filename)
|
||
.font(.body)
|
||
.lineLimit(1)
|
||
Spacer()
|
||
}
|
||
HStack(spacing: 8) {
|
||
Text("+\(file.additions)").foregroundColor(.green)
|
||
.font(.caption2.monospacedDigit())
|
||
Text("−\(file.deletions)").foregroundColor(.red)
|
||
.font(.caption2.monospacedDigit())
|
||
Spacer()
|
||
}
|
||
}
|
||
.padding(.vertical, 6)
|
||
.padding(.horizontal)
|
||
}
|
||
|
||
private func statusIcon(_ file: ChangedFile) -> String {
|
||
switch file.status ?? "" {
|
||
case "added": return "+"
|
||
case "removed": return "-"
|
||
case "renamed": return "→"
|
||
default: return "✎"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Commits
|
||
|
||
struct CommitsTab: View {
|
||
let commits: [PRCommit]
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
ForEach(commits) { commit in
|
||
HStack(alignment: .top, spacing: 10) {
|
||
Text(commit.sha ?? commit.id)
|
||
.font(.caption2.monospaced())
|
||
.foregroundColor(.secondary)
|
||
.frame(width: 60, alignment: .leading)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(commit.commit?.message ?? "(no message)")
|
||
.font(.callout)
|
||
.lineLimit(3)
|
||
Text(commit.author?.name ?? "")
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
Divider()
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
// MARK: - Checks
|
||
|
||
struct ChecksTab: View {
|
||
@ObservedObject var vm: PRDetailViewModel
|
||
let onRerun: (ActionRun) -> Void
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
if vm.runs.isEmpty {
|
||
Text("No workflow runs for this head commit.")
|
||
.font(.callout)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
ForEach(vm.runs) { run in
|
||
runCard(run)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding()
|
||
}
|
||
|
||
private func runCard(_ run: ActionRun) -> some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
CICircle(status: statusFor(run))
|
||
Text(run.name ?? "workflow")
|
||
.font(.subheadline.weight(.semibold))
|
||
Spacer()
|
||
if let started = run.createdAt {
|
||
Text(started, style: .relative)
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
if let jobs = vm.jobs[run.id], !jobs.isEmpty {
|
||
ForEach(jobs) { job in
|
||
HStack {
|
||
CICircle(status: statusFor(job))
|
||
Text(job.name).font(.caption)
|
||
Spacer()
|
||
if isFailed(job) {
|
||
Button("Re-run") {
|
||
onRerun(run)
|
||
}
|
||
.font(.caption)
|
||
.buttonStyle(.bordered)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(10)
|
||
.background(Color(.secondarySystemBackground))
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
}
|
||
|
||
private func statusFor(_ run: ActionRun) -> CIStatus {
|
||
let c = (run.conclusion ?? "").lowercased()
|
||
let s = (run.status ?? "").lowercased()
|
||
if s == "running" || s == "queued" || s == "waiting" { return .running }
|
||
switch c {
|
||
case "success": return .success
|
||
case "failure": return .failure
|
||
case "cancelled": return .cancelled
|
||
default: return .unknown
|
||
}
|
||
}
|
||
|
||
private func statusFor(_ job: ActionJob) -> CIStatus {
|
||
let c = (job.conclusion ?? "").lowercased()
|
||
let s = job.status.lowercased()
|
||
if s == "running" || s == "queued" { return .running }
|
||
switch c {
|
||
case "success": return .success
|
||
case "failure": return .failure
|
||
case "cancelled": return .cancelled
|
||
default: return .unknown
|
||
}
|
||
}
|
||
|
||
private func isFailed(_ job: ActionJob) -> Bool {
|
||
let c = (job.conclusion ?? "").lowercased()
|
||
return c == "failure" || c == "cancelled"
|
||
}
|
||
}
|