commit 10256349780263d3a61f9a4bcb552ef7b296154c Author: Hermes Date: Wed Aug 12 17:07:58 2026 +0000 GiteaPR: native iOS app to manage Gitea pull requests 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. diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..260590a --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,88 @@ +name: Build iOS App + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build: + name: Build .ipa and publish source + runs-on: macos + env: + # Per-app: change APP_NAME for each app repo. Used everywhere so the + # perlis dir name and file names always match (no hardcoded drift). + APP_NAME: GiteaPR + # Point xcodebuild at full Xcode without sudo (sudo hangs in CI). + # DEVELOPER_DIR overrides the active developer directory for all steps. + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: | + xcodebuild -version + + - name: Build unsigned .ipa + run: | + # Build the app bundle (no signing — SideStore signs on-device) + xcodebuild \ + -project $APP_NAME.xcodeproj \ + -scheme $APP_NAME \ + -configuration Release \ + -sdk iphoneos \ + -destination 'generic/platform=iOS' \ + -derivedDataPath build \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + build + + # Locate the .app bundle + APP_PATH=$(find build/Build/Products -name "*.app" -type d | head -1) + echo "App bundle: $APP_PATH" + + # Package into an .ipa (Payload/App.app structure) + mkdir -p dist/Payload + cp -R "$APP_PATH" dist/Payload/ + cd dist + zip -r ../$APP_NAME.ipa Payload + cd .. + ls -la $APP_NAME.ipa + + - name: Generate app metadata + run: | + VERSION="1.0" + SIZE=$(stat -f%z $APP_NAME.ipa) + DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + # Per-app metadata file. The perlis aggregator folds this into the + # root source.json at https://apps.bueso.eu/source.json. + cat > $APP_NAME.json < + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GiteaPR/AppSession.swift b/GiteaPR/AppSession.swift new file mode 100644 index 0000000..ed101f2 --- /dev/null +++ b/GiteaPR/AppSession.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Lightweight in-memory session — holds the current user's login for +/// "assigned to me" / "created by me" filters. Populated by the Settings +/// "Test connection" call via /user. +enum AppSession { + static var currentLogin: String = "" +} diff --git a/GiteaPR/Assets.xcassets/AppIcon.appiconset/Contents.json b/GiteaPR/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/GiteaPR/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GiteaPR/Assets.xcassets/Contents.json b/GiteaPR/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/GiteaPR/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GiteaPR/Detail/DiffView.swift b/GiteaPR/Detail/DiffView.swift new file mode 100644 index 0000000..2451ede --- /dev/null +++ b/GiteaPR/Detail/DiffView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +/// Read-only unified diff viewer for a single changed file. +/// +/// Parses a Gitea unified diff patch into line hunks so added/deleted lines can +/// be colour-coded. Gitea only returns `patch` for small diffs; large files +/// fall back to a raw text view. +struct DiffView: View { + let file: ChangedFile + + var body: some View { + Group { + if let patch = file.patch, !patch.isEmpty { + ScrollView([.horizontal, .vertical]) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(parse(patch)) { line in + diffLine(line) + } + } + .font(.caption.monospaced()) + } + } else { + ContentUnavailableView( + "Diff too large to preview", + systemImage: "doc.text", + description: Text("Gitea didn't return a patch for this file.")) + } + } + .navigationTitle(file.filename) + .navigationBarTitleDisplayMode(.inline) + } + + private func diffLine(_ line: DiffLine) -> some View { + Text(line.text) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 1) + .background(background(for: line.type)) + .foregroundColor(foreground(for: line.type)) + } + + private func background(for type: DiffLineType) -> Color { + switch type { + case .addition: return Color.green.opacity(0.15) + case .deletion: return Color.red.opacity(0.15) + case .hunk, .meta: return Color(.secondarySystemBackground) + case .context: return .clear + } + } + private func foreground(for type: DiffLineType) -> Color { + switch type { + case .addition: return .green + case .deletion: return .red + case .hunk: return .secondary + case .meta, .context: return .primary + } + } + + private func parse(_ patch: String) -> [DiffLine] { + patch.components(separatedBy: "\n").map { line in + if line.hasPrefix("@@") { return DiffLine(text: line, type: .hunk) } + if line.hasPrefix("diff ") || line.hasPrefix("index ") + || line.hasPrefix("--- ") || line.hasPrefix("+++ ") { + return DiffLine(text: line, type: .meta) + } + if line.hasPrefix("+") { return DiffLine(text: line, type: .addition) } + if line.hasPrefix("-") { return DiffLine(text: line, type: .deletion) } + return DiffLine(text: line, type: .context) + } + } +} + +private enum DiffLineType { case context, addition, deletion, hunk, meta } + +private struct DiffLine: Identifiable { + let text: String + let type: DiffLineType + var id: UUID { UUID() } +} diff --git a/GiteaPR/Detail/PRDetailView.swift b/GiteaPR/Detail/PRDetailView.swift new file mode 100644 index 0000000..78ee385 --- /dev/null +++ b/GiteaPR/Detail/PRDetailView.swift @@ -0,0 +1,184 @@ +import SwiftUI + +struct PRDetailView: View { + @StateObject private var vm: PRDetailViewModel + @State private var selectedTab: DetailTab = .conversation + @State private var showActionSheet = false + @State private var showCommentSheet = false + @State private var showMergeSheet = false + @State private var showCloseConfirm = false + @State private var showRerunConfirm: ActionRun? + @State private var requestChangesMode = false + + enum DetailTab: String, CaseIterable, Identifiable { + case conversation = "Conversation" + case files = "Files" + case commits = "Commits" + case checks = "Checks" + var id: String { rawValue } + } + + init(repo: Repository, prNumber: Int) { + _vm = StateObject(wrappedValue: PRDetailViewModel(repo: repo, + prNumber: prNumber)) + } + + var body: some View { + Group { + if let pr = vm.pr { + content(pr) + } else if vm.isLoading { + ProgressView("Loading…") + } else if let err = vm.errorMessage { + ContentUnavailableView("Couldn't load PR", + systemImage: "exclamationmark.triangle", + description: Text(err)) + .toolbar { ToolbarItem(placement: .primaryAction) { reloadButton } } + } else { + ProgressView("Loading…") + } + } + .navigationTitle("#\(vm.prNumber)") + .navigationBarTitleDisplayMode(.inline) + .task { await vm.load() } + .alert("Error", isPresented: .init( + get: { vm.errorMessage != nil }, + set: { if !$0 { vm.errorMessage = nil } })) { + Button("OK", role: .cancel) {} + } message: { Text(vm.errorMessage ?? "") } + .overlay { if vm.busyMessage != nil { busyOverlay } } + .confirmationDialog("Actions", isPresented: $showActionSheet, titleVisibility: .visible) { + Button("Comment") { showCommentSheet = true } + Button("Approve") { Task { await vm.submitReview(state: "approved", comment: nil) } } + Button("Request changes") { requestChangesMode = true; showCommentSheet = true } + Button("Merge") { showMergeSheet = true } + if prIsOpen { + Button("Close", role: .destructive) { showCloseConfirm = true } + } else { + Button("Reopen") { Task { await vm.setState("open") } } + } + } + .confirmationDialog("Close this PR?", isPresented: $showCloseConfirm, titleVisibility: .visible) { + Button("Close", role: .destructive) { Task { await vm.setState("closed") } } + Button("Cancel", role: .cancel) {} + } message: { Text("This will close the pull request.") } + .sheet(isPresented: $showCommentSheet) { + CommentSheet(vm: vm, + mode: requestChangesMode ? .requestChanges : .comment) + .onDisappear { requestChangesMode = false } + } + .sheet(isPresented: $showMergeSheet) { MergeSheet(vm: vm) } + .confirmationDialog("Re-run workflow?", + isPresented: .init( + get: { showRerunConfirm != nil }, + set: { if !$0 { showRerunConfirm = nil } }), + titleVisibility: .visible) { + Button("Re-run") { + if let run = showRerunConfirm { + Task { await vm.rerun(runID: run.id) } + } + showRerunConfirm = nil + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Re-run \"\(showRerunConfirm?.name ?? "workflow")\"?") + } + } + + @ViewBuilder + private func content(_ pr: PullRequest) -> some View { + VStack(spacing: 0) { + header(pr) + Picker("Tab", selection: $selectedTab) { + ForEach(DetailTab.allCases) { t in Text(t.rawValue).tag(t) } + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.bottom, 8) + + ScrollView { + switch selectedTab { + case .conversation: ConversationTab(vm: vm) + case .files: FilesTab(vm: vm) + case .commits: CommitsTab(commits: vm.commits) + case .checks: ChecksTab(vm: vm, onRerun: { showRerunConfirm = $0 }) + } + } + + if prIsOpen { + actionBar(pr) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { reloadButton } + } + } + + @ViewBuilder + private func header(_ pr: PullRequest) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(pr.title) + .font(.title3.weight(.bold)) + HStack(spacing: 6) { + Text("\(vm.repo.fullName)#\(pr.number)") + .font(.caption) + .foregroundColor(.secondary) + CICircle(status: vm.ciStatus) + if vm.reviewState != .unknown { + ReviewBadge(state: vm.reviewState) + } + if pr.draft == true { + Text("Draft").font(.caption2).foregroundColor(.secondary) + } + } + if let body = pr.body, !body.isEmpty { + Text(body) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground)) + } + + @ViewBuilder + private func actionBar(_ pr: PullRequest) -> some View { + HStack { + Button { showActionSheet = true } label: { + Label("Review", systemImage: "checkmark.bubble") + } + .buttonStyle(.borderedProminent) + + Button("Merge") { showMergeSheet = true } + .buttonStyle(.bordered) + .disabled(vm.ciStatus == .failure && + SettingsStore.shared.mergeGateMode == .block) + + Spacer() + } + .padding() + .background(.bar) + } + + private var prIsOpen: Bool { + (vm.pr?.state.lowercased() ?? "") == "open" + } + + private var reloadButton: some View { + Button { Task { await vm.load() } } label: { + Image(systemName: "arrow.clockwise") + } + } + + private var busyOverlay: some View { + ZStack { + Color.black.opacity(0.15) + ProgressView(vm.busyMessage ?? "") + .padding() + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .ignoresSafeArea() + } +} diff --git a/GiteaPR/Detail/PRDetailViewModel.swift b/GiteaPR/Detail/PRDetailViewModel.swift new file mode 100644 index 0000000..e2a43d9 --- /dev/null +++ b/GiteaPR/Detail/PRDetailViewModel.swift @@ -0,0 +1,151 @@ +import Foundation +import SwiftUI + +@MainActor +final class PRDetailViewModel: ObservableObject { + let repo: Repository + let prNumber: Int + + @Published var pr: PullRequest? + @Published var comments: [IssueComment] = [] + @Published var reviews: [PullReview] = [] + @Published var files: [ChangedFile] = [] + @Published var commits: [PRCommit] = [] + @Published var runs: [ActionRun] = [] + @Published var jobs: [Int64: [ActionJob]] = [:] + + @Published var isLoading = false + @Published var isActionsLoading = false + @Published var errorMessage: String? + @Published var busyMessage: String? + + private var owner: String { repo.owner.login } + + init(repo: Repository, prNumber: Int) { + self.repo = repo + self.prNumber = prNumber + } + + var ciStatus: CIStatus { CIStatus.from(runs: runs) } + var reviewState: ReviewState { ReviewState.from(reviews: reviews) } + + var hasFailedRun: Bool { + runs.contains { + let c = ($0.conclusion ?? "").lowercased() + return c == "failure" || c == "cancelled" + } + } + + func load() async { + guard let client = SettingsStore.shared.client() else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + async let prT = client.pullRequest(owner: owner, repo: repo.name, number: prNumber) + async let commentsT = client.comments(owner: owner, repo: repo.name, number: prNumber) + async let reviewsT = client.reviews(owner: owner, repo: repo.name, number: prNumber) + async let filesT = client.changedFiles(owner: owner, repo: repo.name, number: prNumber) + async let commitsT = client.commits(owner: owner, repo: repo.name, number: prNumber) + pr = try await prT + comments = try await commentsT + reviews = try await reviewsT + files = try await filesT + commits = try await commitsT + // CI runs depend on the head SHA from the PR. + if let sha = pr?.head?.sha, !sha.isEmpty { + await loadRuns(client: client, sha: sha) + } + } catch { + errorMessage = error.localizedDescription + } + } + + private func loadRuns(client: GiteaClient, sha: String) async { + isActionsLoading = true + defer { isActionsLoading = false } + do { + runs = try await client.runs(owner: owner, repo: repo.name, sha: sha) + // Load jobs for each run concurrently. + for run in runs { + let jobList = (try? await client.jobs(owner: owner, repo: repo.name, + runID: run.id)) ?? [] + jobs[run.id] = jobList + } + } catch { + // CI is best-effort; don't fail the whole detail view. + } + } + + func rerun(runID: Int64) async { + guard let client = SettingsStore.shared.client() else { return } + busyMessage = "Re-running job…" + defer { busyMessage = nil } + do { + try await client.rerun(owner: owner, repo: repo.name, runID: runID) + if let sha = pr?.head?.sha { + await loadRuns(client: client, sha: sha) + } + } catch { + errorMessage = error.localizedDescription + } + } + + func postComment(_ body: String) async { + guard let client = SettingsStore.shared.client() else { return } + busyMessage = "Posting…" + defer { busyMessage = nil } + do { + let c = try await client.postComment(owner: owner, repo: repo.name, + number: prNumber, body: body) + comments.append(c) + } catch { + errorMessage = error.localizedDescription + } + } + + func submitReview(state: String, comment: String?) async { + guard let client = SettingsStore.shared.client() else { return } + busyMessage = "Submitting…" + defer { busyMessage = nil } + do { + let r = try await client.submitReview(owner: owner, repo: repo.name, + number: prNumber, state: state, + comment: comment) + reviews.append(r) + } catch { + errorMessage = error.localizedDescription + } + } + + func merge(method: String) async -> Bool { + guard let client = SettingsStore.shared.client() else { return false } + busyMessage = "Merging…" + defer { busyMessage = nil } + do { + try await client.merge(owner: owner, repo: repo.name, + number: prNumber, method: method) + // Refresh the PR state to reflect the merge. + pr = try? await client.pullRequest(owner: owner, repo: repo.name, + number: prNumber) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func setState(_ state: String) async { + guard let client = SettingsStore.shared.client() else { return } + busyMessage = "Updating…" + defer { busyMessage = nil } + do { + try await client.setState(owner: owner, repo: repo.name, + number: prNumber, state: state) + pr = try? await client.pullRequest(owner: owner, repo: repo.name, + number: prNumber) + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/GiteaPR/Detail/Sheets.swift b/GiteaPR/Detail/Sheets.swift new file mode 100644 index 0000000..f2be0a0 --- /dev/null +++ b/GiteaPR/Detail/Sheets.swift @@ -0,0 +1,135 @@ +import SwiftUI + +/// Composes a comment. `mode` picks whether it posts a plain comment or a +/// review with a state (Approve / Request changes / Comment). +struct CommentSheet: View { + @ObservedObject var vm: PRDetailViewModel + @Environment(\.dismiss) private var dismiss + @State private var text = "" + var mode: CommentMode = .comment + + enum CommentMode { case comment, approve, requestChanges } + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 12) { + Text(title).font(.headline) + TextEditor(text: $text) + .frame(minHeight: 150) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(.separator))) + Text("\(text.count) characters") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + } + .padding() + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Submit") { submit() } + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } + + private var title: String { + switch mode { + case .approve: return "Approve" + case .requestChanges: return "Request changes" + case .comment: return "Comment" + } + } + + private func submit() { + let body = text.trimmingCharacters(in: .whitespacesAndNewlines) + switch mode { + case .approve: + Task { await vm.submitReview(state: "approved", comment: body) } + case .requestChanges: + Task { await vm.submitReview(state: "changes", comment: body) } + case .comment: + Task { await vm.postComment(body) } + } + dismiss() + } +} + +struct MergeSheet: View { + @ObservedObject var vm: PRDetailViewModel + @Environment(\.dismiss) private var dismiss + @State private var method = "merge" + + private let methods = [("merge", "Create merge commit"), + ("rebase", "Rebase and merge"), + ("squash", "Squash and merge")] + + var body: some View { + NavigationStack { + Form { + Section("Merge method") { + Picker("Method", selection: $method) { + ForEach(methods, id: \.0) { m in + Text(m.1).tag(m.0) + } + } + .pickerStyle(.inline) + } + Section { + HStack { + Text("CI") + Spacer() + switch vm.ciStatus { + case .success: Label("Passing", systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + case .failure: Label("Failing", systemImage: "xmark.circle.fill") + .foregroundColor(.red) + case .running: Label("Running", systemImage: "hourglass") + .foregroundColor(.orange) + case .cancelled, .unknown: Label("Unknown", systemImage: "questionmark.circle") + .foregroundColor(.secondary) + } + } + if vm.ciStatus == .failure { + if SettingsStore.shared.mergeGateMode == .block { + Label("Merging is blocked while CI is red (Settings).", + systemImage: "lock") + .font(.caption) + .foregroundColor(.secondary) + } else { + Label("CI is red — you can still merge (Settings allows it).", + systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundColor(.orange) + } + } + } + } + .navigationTitle("Merge") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Merge") { performMerge() } + .disabled(SettingsStore.shared.mergeGateMode == .block + && vm.ciStatus == .failure) + } + } + } + } + + private func performMerge() { + Task { + let success = await vm.merge(method: method) + if success { dismiss() } + } + } +} diff --git a/GiteaPR/Detail/Tabs.swift b/GiteaPR/Detail/Tabs.swift new file mode 100644 index 0000000..3954b63 --- /dev/null +++ b/GiteaPR/Detail/Tabs.swift @@ -0,0 +1,258 @@ +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" + } +} diff --git a/GiteaPR/GiteaPRApp.swift b/GiteaPR/GiteaPRApp.swift new file mode 100644 index 0000000..2db8283 --- /dev/null +++ b/GiteaPR/GiteaPRApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct GiteaPRApp: App { + @StateObject private var settings = SettingsStore.shared + + var body: some Scene { + WindowGroup { + InboxView() + .environmentObject(settings) + } + } +} diff --git a/GiteaPR/Inbox/InboxViewModel.swift b/GiteaPR/Inbox/InboxViewModel.swift new file mode 100644 index 0000000..d845ce5 --- /dev/null +++ b/GiteaPR/Inbox/InboxViewModel.swift @@ -0,0 +1,121 @@ +import Foundation +import SwiftUI + +enum InboxFilter: String, CaseIterable, Identifiable { + case all = "All" + case assigned = "Assigned" + case created = "By me" + case review = "Review" + + var id: String { rawValue } +} + +/// A PR row enriched with its repo and aggregate status. +struct PRRow: Identifiable, Hashable { + let repo: Repository + let pr: PullRequest + let ciStatus: CIStatus + let reviewState: ReviewState + + var id: String { "\(repo.fullName)#\(pr.number)" } + var fullName: String { repo.fullName } +} + +@MainActor +final class InboxViewModel: ObservableObject { + @Published var rows: [PRRow] = [] + @Published var isLoading = false + @Published var errorMessage: String? + @Published var filter: InboxFilter = .all { + didSet { recomputeDisplayed() } + } + // Raw rows before filtering, kept so switching filters is instant. + private var allRows: [PRRow] = [] + @Published var displayedRows: [PRRow] = [] + + var isConfigured: Bool { SettingsStore.shared.isConfigured } + + private func recomputeDisplayed() { + switch filter { + case .all: + displayedRows = allRows + case .assigned: + displayedRows = allRows.filter { + ($0.pr.assignees ?? []).contains { + $0.login == AppSession.currentLogin + } + } + case .created: + displayedRows = allRows.filter { + $0.pr.user?.login == AppSession.currentLogin + } + case .review: + displayedRows = allRows.filter { + switch $0.reviewState { + case .needsReview, .unknown: return true + default: return false + } + } + } + } + + func load() async { + guard isConfigured, let client = SettingsStore.shared.client() else { + errorMessage = "Configure your Gitea instance in Settings." + return + } + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let repos = try await client.repos() + // Fetch open PRs across all repos concurrently. + var rows: [PRRow] = [] + try await withThrowingTaskGroup(of: [PRRow].self) { group in + for repo in repos { + group.addTask { + let prs = try await client.pullRequests(owner: repo.owner.login, + repo: repo.name) + return try await self.enrich(prs, in: repo, client: client) + } + } + for try await batch in group { + rows.append(contentsOf: batch) + } + } + // Sort: most recently updated first. + rows.sort { ($0.pr.updatedAt ?? .distantPast) > ($1.pr.updatedAt ?? .distantPast) } + allRows = rows + recomputeDisplayed() + } catch { + errorMessage = error.localizedDescription + allRows = [] + recomputeDisplayed() + } + } + + /// Enrich each PR with CI + review status. Runs sequentially per repo to + /// avoid hammering the API from one inbox refresh. + private func enrich(_ prs: [PullRequest], in repo: Repository, + client: GiteaClient) async throws -> [PRRow] { + var out: [PRRow] = [] + for pr in prs { + let sha = pr.head?.sha + var ci: CIStatus = .unknown + var review: ReviewState = .unknown + if let sha, !sha.isEmpty { + let runs = (try? await client.runs(owner: repo.owner.login, + repo: repo.name, sha: sha)) ?? [] + ci = CIStatus.from(runs: runs) + } + let reviews = (try? await client.reviews(owner: repo.owner.login, + repo: repo.name, + number: pr.number)) ?? [] + review = ReviewState.from(reviews: reviews) + out.append(PRRow(repo: repo, pr: pr, + ciStatus: ci, reviewState: review)) + } + return out + } +} diff --git a/GiteaPR/Models/Actions.swift b/GiteaPR/Models/Actions.swift new file mode 100644 index 0000000..b845a56 --- /dev/null +++ b/GiteaPR/Models/Actions.swift @@ -0,0 +1,46 @@ +import Foundation + +/// A Gitea Actions workflow run, from +/// `/repos/{owner}/{repo}/actions/runs?head_sha=...`. +struct ActionRun: Codable, Identifiable { + struct Workflow: Codable { + let id: Int64? + let name: String? + let headBranch: String? + } + let id: Int64 + let name: String? // workflow file name (e.g. "Build iOS App") + let workflowId: Int64? + let headBranch: String? + let headSha: String? + let status: String? // waiting | running | success | failure | cancelled + let conclusion: String? // null while running, else success/failure/etc. + let createdAt: Date? + let updatedAt: Date? + + enum CodingKeys: String, CodingKey { + case id, name + case workflowId = "workflow_id" + case headBranch = "head_branch" + case headSha = "head_sha" + case status, conclusion + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +/// A job within a run, from `/repos/{owner}/{repo}/actions/runs/{id}/jobs`. +struct ActionJob: Codable, Identifiable { + let id: Int64 + let name: String + let status: String // queued | running | success | failure | cancelled + let conclusion: String? + let startedAt: Date? + let completedAt: Date? + + enum CodingKeys: String, CodingKey { + case id, name, status, conclusion + case startedAt = "started_at" + case completedAt = "completed_at" + } +} diff --git a/GiteaPR/Models/ChangedFile.swift b/GiteaPR/Models/ChangedFile.swift new file mode 100644 index 0000000..e5a6832 --- /dev/null +++ b/GiteaPR/Models/ChangedFile.swift @@ -0,0 +1,16 @@ +import Foundation + +/// One file changed by a PR, from `/repos/{owner}/{repo}/pulls/{index}/files`. +struct ChangedFile: Codable, Identifiable, Hashable { + let filename: String + let status: String? // added | removed | modified | renamed + let additions: Int + let deletions: Int + let patch: String? // unified diff hunks (only for small diffs) + + var id: String { filename } + + enum CodingKeys: String, CodingKey { + case filename, status, additions, deletions, patch + } +} diff --git a/GiteaPR/Models/PullRequest.swift b/GiteaPR/Models/PullRequest.swift new file mode 100644 index 0000000..c203876 --- /dev/null +++ b/GiteaPR/Models/PullRequest.swift @@ -0,0 +1,103 @@ +import Foundation + +struct Branch: Codable, Hashable { + let label: String? + let ref: String? + let sha: String? +} + +struct Label: Codable, Hashable, Identifiable { + let id: Int64? + let name: String + let color: String? +} + +struct PRUser: Codable, Hashable { + let id: Int64? + let login: String + let fullName: String? +} + +/// A pull request as returned by `/repos/{owner}/{repo}/pulls` and +/// `/issues/{index}`. +struct PullRequest: Codable, Hashable, Identifiable { + let id: Int64 + let number: Int + let title: String + let body: String? + let state: String // open | closed | merged + let htmlURL: String? + let diffURL: String? + let patchURL: String? + let user: PRUser? + let assignees: [PRUser]? + let labels: [Label]? + let head: Branch? + let base: Branch? + let createdAt: Date? + let updatedAt: Date? + let merged: Bool? + let mergeable: Bool? + let draft: Bool? + + enum CodingKeys: String, CodingKey { + case id, number, title, body, state, user, assignees, labels + case htmlURL = "html_url" + case diffURL = "diff_url" + case patchURL = "patch_url" + case head, base + case createdAt = "created_at" + case updatedAt = "updated_at" + case merged, mergeable, draft + } +} + +/// A single review submission (approve / request changes / comment). +struct PullReview: Codable, Identifiable { + let id: Int64 + let state: String // APPROVED | REQUEST_CHANGES | COMMENT | PENDING + let body: String? + let user: PRUser? + let submittedAt: Date? + + enum CodingKeys: String, CodingKey { + case id, state, body, user + case submittedAt = "submitted_at" + } +} + +/// An issue/PR comment. +struct IssueComment: Codable, Identifiable { + let id: Int64 + let body: String? + let user: PRUser? + let createdAt: Date? + let updatedAt: Date? + + enum CodingKeys: String, CodingKey { + case id, body, user + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +/// A commit in a pull request. +struct PRCommit: Codable, Identifiable { + struct Meta: Codable { + let sha: String? + let message: String? + } + struct Author: Codable { + let name: String? + let email: String? + let login: String? + } + let id: String // sha + let sha: String? + let commit: Meta? + let author: Author? + + enum CodingKeys: String, CodingKey { + case id, sha, commit, author + } +} diff --git a/GiteaPR/Models/Repository.swift b/GiteaPR/Models/Repository.swift new file mode 100644 index 0000000..ae3f7d4 --- /dev/null +++ b/GiteaPR/Models/Repository.swift @@ -0,0 +1,28 @@ +import Foundation + +/// A GitHub/Gitea style owner (user or organization). +struct Owner: Codable, Hashable { + let id: Int64? + let login: String + let fullName: String? +} + +/// A repository as returned by `/user/repos`. +struct Repository: Codable, Hashable, Identifiable { + let id: Int64 + let name: String + let fullName: String + let owner: Owner + let `private`: Bool? + let htmlURL: String? + let defaultBranch: String? + + enum CodingKeys: String, CodingKey { + case id, name + case fullName = "full_name" + case owner + case `private` + case htmlURL = "html_url" + case defaultBranch = "default_branch" + } +} diff --git a/GiteaPR/Networking/APIError.swift b/GiteaPR/Networking/APIError.swift new file mode 100644 index 0000000..873ba53 --- /dev/null +++ b/GiteaPR/Networking/APIError.swift @@ -0,0 +1,32 @@ +import Foundation + +enum APIError: LocalizedError, Equatable { + case invalidURL + case invalidResponse + case httpStatus(Int, String?) + case decoding(String) + case unauthorized + case notFound + + var errorDescription: String? { + switch self { + case .invalidURL: + return "The instance URL is invalid." + case .invalidResponse: + return "The server returned an invalid response." + case .httpStatus(let code, let message): + if code == 401 || code == 403 { + return "Authentication failed. Check your token." + } + return message?.isEmpty == false + ? "Server error (\(code)): \(message!)" + : "Server error (\(code))." + case .decoding(let detail): + return "Could not parse the server response: \(detail)" + case .unauthorized: + return "Authentication failed. Check your token." + case .notFound: + return "Not found." + } + } +} diff --git a/GiteaPR/Networking/GiteaClient.swift b/GiteaPR/Networking/GiteaClient.swift new file mode 100644 index 0000000..4624208 --- /dev/null +++ b/GiteaPR/Networking/GiteaClient.swift @@ -0,0 +1,208 @@ +import Foundation + +/// Thin async/await wrapper around the Gitea REST API. +/// +/// Gitea endpoints used: +/// GET /user/repos — all accessible repos +/// GET /repos/{o}/{r}/pulls?state=open — open PRs (multi-repo inbox) +/// GET /repos/{o}/{r}/pulls/{n} — PR detail +/// GET /repos/{o}/{r}/pulls/{n}/files — changed files +/// GET /repos/{o}/{r}/pulls/{n}/commits — commits +/// GET /repos/{o}/{r}/issues/{n}/comments — PR comments +/// GET /repos/{o}/{r}/pulls/{n}/reviews — review submissions +/// POST /repos/{o}/{r}/pulls/{n}/reviews — approve / request changes / comment +/// POST /repos/{o}/{r}/issues/{n}/comments — post comment +/// POST /repos/{o}/{r}/pulls/{n}/merge — merge +/// PATCH /repos/{o}/{r}/pulls/{n} — close / reopen +/// GET /repos/{o}/{r}/actions/runs?head_sha=... — CI runs for the PR head +/// GET /repos/{o}/{r}/actions/runs/{id}/jobs — jobs in a run +/// POST /repos/{o}/{r}/actions/runs/{id}/rerun — re-run a failed run +struct GiteaClient { + let instanceURL: URL + let token: String + let session: URLSession + + init(instanceURL: URL, token: String, session: URLSession = .shared) { + self.instanceURL = instanceURL + self.token = token + self.session = session + } + + private func request(_ path: String, + method: String = "GET", + query: [URLQueryItem]? = nil, + body: Data? = nil) async throws -> Data { + var url = instanceURL.appendingPathComponent("api/v1").appendingPathComponent(path) + if let query, !query.isEmpty { + var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)! + var items = comps.queryItems ?? [] + items.append(contentsOf: query) + comps.queryItems = items + url = comps.url! + } + + var req = URLRequest(url: url) + req.httpMethod = method + req.setValue("token \(token)", forHTTPHeaderField: "Authorization") + req.setValue("application/json", forHTTPHeaderField: "Accept") + if let body { + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = body + } + + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + switch http.statusCode { + case 200...299: + return data + case 401, 403: + throw APIError.unauthorized + case 404: + throw APIError.notFound + default: + let message = (try? JSONDecoder().decode([String: String].self, + from: data))?["message"] + throw APIError.httpStatus(http.statusCode, message) + } + } + + private func get(_ path: String, + query: [URLQueryItem]? = nil) async throws -> T { + let data = try await request(path, query: query) + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw APIError.decoding(String(describing: error)) + } + } + + private func post(_ path: String, body: Data? = nil) async throws -> T { + let data = try await request(path, method: "POST", body: body) + return try JSONDecoder().decode(T.self, from: data) + } + + private func postNoBody(_ path: String) async throws { + _ = try await request(path, method: "POST") + } + + // MARK: - User + + /// Current authenticated user (for the "Test connection" button and + /// for resolving the current login used by inbox filters). + func currentUser() async throws -> PRUser { + try await get("user") + } + + // MARK: - Repos + + func repos() async throws -> [Repository] { + var all: [Repository] = [] + // Fetch paginated. Page size 50; multi-repo inbox across a homelab is + // typically < 50 repos, but loop to be safe. + for page in 1...10 { + let batch: [Repository] = try await get( + "user/repos", + query: [URLQueryItem(name: "page", value: "\(page)"), + URLQueryItem(name: "limit", value: "50")]) + all.append(contentsOf: batch) + if batch.count < 50 { break } + } + return all + } + + // MARK: - Pull requests + + func pullRequests(owner: String, repo: String) async throws -> [PullRequest] { + try await get("repos/\(owner)/\(repo)/pulls", + query: [URLQueryItem(name: "state", value: "open"), + URLQueryItem(name: "limit", value: "100")]) + } + + func pullRequest(owner: String, repo: String, number: Int) async throws -> PullRequest { + try await get("repos/\(owner)/\(repo)/pulls/\(number)") + } + + func changedFiles(owner: String, repo: String, number: Int) async throws -> [ChangedFile] { + try await get("repos/\(owner)/\(repo)/pulls/\(number)/files") + } + + func commits(owner: String, repo: String, number: Int) async throws -> [PRCommit] { + try await get("repos/\(owner)/\(repo)/pulls/\(number)/commits") + } + + func reviews(owner: String, repo: String, number: Int) async throws -> [PullReview] { + try await get("repos/\(owner)/\(repo)/pulls/\(number)/reviews") + } + + func comments(owner: String, repo: String, number: Int) async throws -> [IssueComment] { + try await get("repos/\(owner)/\(repo)/issues/\(number)/comments") + } + + func postComment(owner: String, repo: String, number: Int, + body: String) async throws -> IssueComment { + struct Body: Encodable { let body: String } + return try await post( + "repos/\(owner)/\(repo)/issues/\(number)/comments", + body: try JSONEncoder().encode(Body(body: body))) + } + + func submitReview(owner: String, repo: String, number: Int, + state: String, comment: String?) async throws -> PullReview { + struct Body: Encodable { + let event: String // APPROVED | REQUEST_CHANGES | COMMENT + let body: String? + } + let event: String + switch state { + case "approved": event = "APPROVED" + case "changes": event = "REQUEST_CHANGES" + default: event = "COMMENT" + } + return try await post( + "repos/\(owner)/\(repo)/pulls/\(number)/reviews", + body: try JSONEncoder().encode(Body(event: event, body: comment))) + } + + func merge(owner: String, repo: String, number: Int, + method: String) async throws { + struct Body: Encodable { let Do: String } // 'Do' = merge method: merge|rebase|squash + _ = try await request( + "repos/\(owner)/\(repo)/pulls/\(number)/merge", + method: "POST", + body: try JSONEncoder().encode(Body(Do: method))) + } + + func setState(owner: String, repo: String, number: Int, + state: String) async throws { + struct Body: Encodable { let state: String } + let body = try JSONEncoder().encode(Body(state: state)) + let data = try await request( + "repos/\(owner)/\(repo)/pulls/\(number)", + method: "PATCH", body: body) + _ = data + } + + // MARK: - Actions / CI + + func runs(owner: String, repo: String, sha: String) async throws -> [ActionRun] { + struct Envelope: Decodable { let workflow_runs: [ActionRun]? } + let env: Envelope = try await get( + "repos/\(owner)/\(repo)/actions/runs", + query: [URLQueryItem(name: "head_sha", value: sha), + URLQueryItem(name: "limit", value: "20")]) + return env.workflow_runs ?? [] + } + + func jobs(owner: String, repo: String, runID: Int64) async throws -> [ActionJob] { + struct Envelope: Decodable { let jobs: [ActionJob]? } + let env: Envelope = try await get( + "repos/\(owner)/\(repo)/actions/runs/\(runID)/jobs") + return env.jobs ?? [] + } + + func rerun(owner: String, repo: String, runID: Int64) async throws { + try await postNoBody("repos/\(owner)/\(repo)/actions/runs/\(runID)/rerun") + } +} diff --git a/GiteaPR/Networking/KeychainHelper.swift b/GiteaPR/Networking/KeychainHelper.swift new file mode 100644 index 0000000..8324973 --- /dev/null +++ b/GiteaPR/Networking/KeychainHelper.swift @@ -0,0 +1,50 @@ +import Foundation +import Security + +/// Minimal Keychain wrapper for the API token and instance URL. +enum KeychainHelper { + private static let service = "com.bueso.giteapr" + + static func set(_ value: String, key: String) { + guard let data = value.data(using: .utf8) else { return } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + // Delete any existing item first (upsert semantics). + SecItemDelete(query as CFDictionary) + var attrs = query + attrs[kSecValueData as String] = data + attrs[kSecAttrAccessible as String] = + kSecAttrAccessibleAfterFirstUnlock + SecItemAdd(attrs as CFDictionary, nil) + } + + static func get(_ key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, + let data = result as? Data, + let string = String(data: data, encoding: .utf8) else { + return nil + } + return string + } + + static func delete(_ key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/GiteaPR/Settings/SettingsStore.swift b/GiteaPR/Settings/SettingsStore.swift new file mode 100644 index 0000000..e794679 --- /dev/null +++ b/GiteaPR/Settings/SettingsStore.swift @@ -0,0 +1,54 @@ +import Foundation +import SwiftUI + +enum MergeGateMode: String, CaseIterable, Identifiable { + case warn = "Warn on red (allow merge with confirm)" + case block = "Block merge when CI is red" + + var id: String { rawValue } +} + +/// Central settings: instance + token (Keychain) and app preferences. +@MainActor +final class SettingsStore: ObservableObject { + static let shared = SettingsStore() + + @Published var instanceURL: String { + didSet { KeychainHelper.set(instanceURL, key: "instanceURL") } + } + @Published var token: String { + didSet { KeychainHelper.set(token, key: "token") } + } + @Published var mergeGateMode: MergeGateMode { + didSet { UserDefaults.standard.set(mergeGateMode.rawValue, + forKey: "mergeGateMode") } + } + + private init() { + instanceURL = KeychainHelper.get("instanceURL") ?? "https://gitea.bueso.eu" + token = KeychainHelper.get("token") ?? "" + let raw = UserDefaults.standard.string(forKey: "mergeGateMode") + mergeGateMode = MergeGateMode(rawValue: raw ?? "") ?? .warn + } + + var isConfigured: Bool { + !token.isEmpty && !instanceURL.isEmpty + } + + var baseURL: URL? { + URL(string: instanceURL.hasSuffix("/") ? + String(instanceURL.dropLast()) : instanceURL) + } + + func client() -> GiteaClient? { + guard let base = baseURL else { return nil } + return GiteaClient(instanceURL: base, token: token) + } + + func logout() { + KeychainHelper.delete("instanceURL") + KeychainHelper.delete("token") + instanceURL = "https://gitea.bueso.eu" + token = "" + } +} diff --git a/GiteaPR/Settings/SettingsView.swift b/GiteaPR/Settings/SettingsView.swift new file mode 100644 index 0000000..345a19b --- /dev/null +++ b/GiteaPR/Settings/SettingsView.swift @@ -0,0 +1,129 @@ +import SwiftUI + +struct SettingsView: View { + @Environment(\.dismiss) private var dismiss + @State private var instanceURL: String + @State private var token: String + @State private var mergeGateMode: MergeGateMode + @State private var testResult: TestResult? + @State private var testing = false + @State private var showLogoutConfirm = false + + enum TestResult: Equatable { + case success(String) + case failure(String) + } + + init() { + _instanceURL = State(initialValue: SettingsStore.shared.instanceURL) + _token = State(initialValue: SettingsStore.shared.token) + _mergeGateMode = State(initialValue: SettingsStore.shared.mergeGateMode) + } + + var body: some View { + NavigationStack { + Form { + Section("Server") { + TextField("Instance URL", text: $instanceURL) + .keyboardType(.URL) + .autocapitalization(.none) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + SecureField("Personal access token", text: $token) + Button(testing ? "Testing…" : "Test connection") { + test() + } + .disabled(testing || token.isEmpty) + if let testResult { + testResultView(testResult) + } + } + + Section("Merge gating") { + Picker("Merge behavior", selection: $mergeGateMode) { + ForEach(MergeGateMode.allCases) { m in + Text(m.rawValue).tag(m) + } + } + .pickerStyle(.inline) + Text("Warn mode shows CI status and allows merging a red PR after confirmation. Block mode disables merge while any check is failing.") + .font(.caption) + .foregroundColor(.secondary) + } + + Section { + Button("Log out", role: .destructive) { + showLogoutConfirm = true + } + .disabled(token.isEmpty) + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() } + .disabled(instanceURL.trimmingCharacters(in: .whitespaces) + .isEmpty) + } + } + .confirmationDialog("Log out?", + isPresented: $showLogoutConfirm, + titleVisibility: .visible) { + Button("Log out", role: .destructive) { + SettingsStore.shared.logout() + testResult = nil + dismiss() + } + Button("Cancel", role: .cancel) {} + } + } + } + + @ViewBuilder + private func testResultView(_ result: TestResult) -> some View { + switch result { + case .success(let login): + Label("Connected as \(login)", systemImage: "checkmark.circle.fill") + .font(.callout) + .foregroundColor(.green) + case .failure(let message): + Label(message, systemImage: "xmark.circle.fill") + .font(.callout) + .foregroundColor(.red) + } + } + + private func test() { + testing = true + testResult = nil + let trimmed = instanceURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmed) else { + testResult = .failure("Invalid URL") + testing = false + return + } + let client = GiteaClient(instanceURL: url, token: token) + Task { + do { + let user = try await client.currentUser() + AppSession.currentLogin = user.login + testResult = .success(user.login) + } catch { + testResult = .failure(error.localizedDescription) + } + testing = false + } + } + + private func save() { + SettingsStore.shared.instanceURL = + instanceURL.trimmingCharacters(in: .whitespacesAndNewlines) + SettingsStore.shared.token = token + SettingsStore.shared.mergeGateMode = mergeGateMode + dismiss() + } +} diff --git a/GiteaPR/StatusEnums.swift b/GiteaPR/StatusEnums.swift new file mode 100644 index 0000000..9e247fd --- /dev/null +++ b/GiteaPR/StatusEnums.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Aggregate CI status across a PR's workflow runs. +enum CIStatus: Equatable { + case running + case success + case failure + case cancelled + case unknown + + /// Runs are ordered newest-first. Any still running → running wins. + /// Otherwise: any failure → failure, else success. + static func from(runs: [ActionRun]) -> CIStatus { + guard !runs.isEmpty else { return .unknown } + var sawSuccess = false + for run in runs { + let conclusion = (run.conclusion ?? "").lowercased() + if run.status?.lowercased() == "running" + || run.status?.lowercased() == "queued" + || run.status?.lowercased() == "waiting" { + return .running + } + switch conclusion { + case "success": sawSuccess = true + case "failure", "cancelled": + return conclusion == "cancelled" ? .cancelled : .failure + default: continue + } + } + return sawSuccess ? .success : .unknown + } +} + +/// Aggregate review state across review submissions. +enum ReviewState: Equatable { + case approved + case changesRequested + case needsReview + case unknown + + static func from(reviews: [PullReview]) -> ReviewState { + var hasReview = false + for r in reviews { + switch r.state.uppercased() { + case "APPROVED": return .approved + case "REQUEST_CHANGES": return .changesRequested + default: hasReview = true + } + } + return hasReview ? .needsReview : .unknown + } +} diff --git a/GiteaPR/Views/InboxView.swift b/GiteaPR/Views/InboxView.swift new file mode 100644 index 0000000..9aa1c0b --- /dev/null +++ b/GiteaPR/Views/InboxView.swift @@ -0,0 +1,122 @@ +import SwiftUI + +struct InboxView: View { + @StateObject private var vm = InboxViewModel() + @State private var showSettings = false + + var body: some View { + NavigationStack { + Group { + if !vm.isConfigured { + ContentUnavailableView( + "Not configured", + systemImage: "key", + description: Text("Add your Gitea instance and token in Settings.")) + .toolbar { + ToolbarItem(placement: .primaryAction) { settingsButton } + } + } else if vm.displayedRows.isEmpty && !vm.isLoading { + ContentUnavailableView( + "No open PRs", + systemImage: "tray", + description: Text(vm.filter == .all + ? "Nothing to review." + : "No PRs match this filter.")) + } else { + List { + ForEach(vm.displayedRows) { row in + NavigationLink(value: row) { + PRRowView(row: row) + } + } + } + .refreshable { await vm.load() } + .listStyle(.plain) + } + } + .navigationTitle("Pull Requests") + .toolbar { + ToolbarItem(placement: .primaryAction) { settingsButton } + } + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + Picker("Filter", selection: $vm.filter) { + ForEach(InboxFilter.allCases) { f in + Text(f.rawValue).tag(f) + } + } + .pickerStyle(.segmented) + .disabled(vm.displayedRows.isEmpty && vm.isLoading) + } + } + .navigationDestination(for: PRRow.self) { row in + PRDetailView(repo: row.repo, prNumber: row.pr.number) + } + .task { await vm.load() } + .overlay { + if vm.isLoading { + ProgressView() + } + } + .alert("Error", isPresented: .init( + get: { vm.errorMessage != nil }, + set: { if !$0 { vm.errorMessage = nil } })) { + Button("OK", role: .cancel) {} + } message: { + Text(vm.errorMessage ?? "") + } + .sheet(isPresented: $showSettings) { + SettingsView() + } + } + } + + private var settingsButton: some View { + Button { showSettings = true } label: { + Image(systemName: "gearshape") + } + } +} + +struct PRRowView: View { + let row: PRRow + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text("#\(row.pr.number)") + .font(.subheadline.weight(.bold)) + .foregroundColor(.secondary) + CICircle(status: row.ciStatus) + if row.reviewState != .unknown { + ReviewBadge(state: row.reviewState) + } + if row.pr.draft == true { + Text("Draft") + .font(.caption2) + .foregroundColor(.secondary) + } + } + Text(row.pr.title) + .font(.body.weight(.medium)) + .lineLimit(2) + Text(row.fullName) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + if let updated = row.pr.updatedAt { + Text(relative(updated)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 2) + } + + private func relative(_ date: Date) -> String { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f.localizedString(for: date, relativeTo: Date()) + } +} diff --git a/GiteaPR/Views/StatusBadges.swift b/GiteaPR/Views/StatusBadges.swift new file mode 100644 index 0000000..fe78bdb --- /dev/null +++ b/GiteaPR/Views/StatusBadges.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct CICircle: View { + let status: CIStatus + var body: some View { + Circle() + .fill(color) + .frame(width: 10, height: 10) + } + private var color: Color { + switch status { + case .success: return .green + case .failure: return .red + case .running: return .orange + case .cancelled: return .gray + case .unknown: return .gray.opacity(0.5) + } + } +} + +struct ReviewBadge: View { + let state: ReviewState + var body: some View { + Text(text) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(color.opacity(0.15)) + .foregroundColor(color) + .clipShape(Capsule()) + } + private var text: String { + switch state { + case .approved: return "Approved" + case .changesRequested: return "Changes" + case .needsReview: return "Needs review" + case .unknown: return "" + } + } + private var color: Color { + switch state { + case .approved: return .green + case .changesRequested: return .red + case .needsReview: return .orange + case .unknown: return .gray + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d86687 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# GiteaPR + +Native iOS app to manage Gitea pull requests from your self-hosted instance +(gitea.bueso.eu). Multi-repo inbox, PR review, read-only diff browsing, and +Gitea Actions check status with one-tap re-run. + +Built unsigned via Gitea Actions on the iMac runner and distributed through +the SideStore source at https://apps.bueso.eu/source.json. + +## Build + +```sh +xcodebuild -project GiteaPR.xcodeproj -scheme GiteaPR -configuration Release \ + -sdk iphoneos -destination 'generic/platform=iOS' \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build +``` + +## Setup in the app + +1. Create a personal access token in Gitea + (Settings → Applications) with scopes: + - `read:repository` + - `write:repository` (review, merge, comment) + - `read:user` +2. Enter the instance URL (e.g. `https://gitea.bueso.eu`) and the token in + Settings. The token is stored in the iOS Keychain. + +## Known limitations + +- Inline file review comments are not exposed by the Gitea API; the diff + viewer is read-only and comments are PR-level only. +- No push notifications in v1 (pull-to-refresh + badge). +- No offline queue in v1.