GiteaPR: native iOS app to manage Gitea pull requests
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.
This commit is contained in:
Hermes
2026-08-12 17:07:58 +00:00
commit 1025634978
27 changed files with 2322 additions and 0 deletions
+88
View File
@@ -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 <<EOF
{
"name": "GiteaPR",
"bundleIdentifier": "com.bueso.giteapr",
"developerName": "Luis",
"localizedDescription": "Manage Gitea pull requests from your iPhone.",
"iconURL": "https://apps.bueso.eu/$APP_NAME/icon.png",
"downloadURL": "https://apps.bueso.eu/$APP_NAME/$APP_NAME.ipa",
"version": "$VERSION",
"versionDate": "$DATE",
"size": $SIZE,
"permissions": []
}
EOF
cat $APP_NAME.json
- name: Upload to source host (perlis)
run: |
# Upload .ipa + per-app metadata to perlis via SSH.
# PERLIS_SSH_HOST is the Tailscale MagicDNS name (perlis.bueso.loc),
# reachable from the iMac runner over the Tailscale mesh.
ssh -o StrictHostKeyChecking=no \
"${{ secrets.PERLIS_SSH_USER }}@${{ secrets.PERLIS_SSH_HOST }}" \
"mkdir -p /mnt/appdata/ios-apps/$APP_NAME"
scp -o StrictHostKeyChecking=no \
$APP_NAME.ipa $APP_NAME.json \
"${{ secrets.PERLIS_SSH_USER }}@${{ secrets.PERLIS_SSH_HOST }}:/mnt/appdata/ios-apps/$APP_NAME/"
+10
View File
@@ -0,0 +1,10 @@
# Xcode
build/
DerivedData/
*.ipa
dist/
xcuserdata/
*.xcuserstate
# macOS
.DS_Store
+257
View File
@@ -0,0 +1,257 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileSystemSynchronizedRootGroup section */
AB1234567890ABCDEF000001 /* GiteaPR */ = {
isa = PBXFileSystemSynchronizedRootGroup;
explicitFileTypes = {
};
explicitFolders = (
);
path = GiteaPR;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXGroup section */
AB1234567890ABCDEF000002 = {
isa = PBXGroup;
children = (
AB1234567890ABCDEF000001 /* GiteaPR */,
AB1234567890ABCDEF000003 /* Products */,
);
sourceTree = "<group>";
};
AB1234567890ABCDEF000003 /* Products */ = {
isa = PBXGroup;
children = (
AB1234567890ABCDEF000004 /* GiteaPR.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
AB1234567890ABCDEF000005 /* GiteaPR */ = {
isa = PBXNativeTarget;
buildConfigurationList = AB1234567890ABCDEF000006 /* Build configuration list for PBXNativeTarget "GiteaPR" */;
buildPhases = (
AB1234567890ABCDEF000007 /* Sources */,
AB1234567890ABCDEF000008 /* Frameworks */,
AB1234567890ABCDEF000009 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
AB1234567890ABCDEF000001 /* GiteaPR */,
);
name = "GiteaPR";
packageProductDependencies = (
);
productName = "GiteaPR";
productReference = AB1234567890ABCDEF000004 /* GiteaPR.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
AB1234567890ABCDEF00000A /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 1600;
TargetAttributes = {
AB1234567890ABCDEF000005 = {
CreatedOnToolsVersion = 16.0;
};
};
};
buildConfigurationList = AB1234567890ABCDEF00000B /* Build configuration list for PBXProject "GiteaPR" */;
compatibilityVersion = "Xcode 15.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = AB1234567890ABCDEF000002;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = AB1234567890ABCDEF000003 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
AB1234567890ABCDEF000005 /* GiteaPR */,
);
};
/* End PBXProject section */
/* Begin PBXFileReference section */
AB1234567890ABCDEF000004 /* GiteaPR.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "GiteaPR.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXSourcesBuildPhase section */
AB1234567890ABCDEF000007 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXFrameworksBuildPhase section */
AB1234567890ABCDEF000008 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXResourcesBuildPhase section */
AB1234567890ABCDEF000009 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
AB1234567890ABCDEF00000C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
AB1234567890ABCDEF00000D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
AB1234567890ABCDEF00000E /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR";
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AB1234567890ABCDEF00000F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR";
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
AB1234567890ABCDEF00000B /* Build configuration list for PBXProject "GiteaPR" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AB1234567890ABCDEF00000C /* Debug */,
AB1234567890ABCDEF00000D /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AB1234567890ABCDEF000006 /* Build configuration list for PBXNativeTarget "GiteaPR" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AB1234567890ABCDEF00000E /* Debug */,
AB1234567890ABCDEF00000F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = AB1234567890ABCDEF00000A /* Project object */;
}
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AB1234567890ABCDEF000005"
BuildableName = "GiteaPR.app"
BlueprintName = "GiteaPR"
ReferencedContainer = "container:GiteaPR.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AB1234567890ABCDEF000005"
BuildableName = "GiteaPR.app"
BlueprintName = "GiteaPR"
ReferencedContainer = "container:GiteaPR.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AB1234567890ABCDEF000005"
BuildableName = "GiteaPR.app"
BlueprintName = "GiteaPR"
ReferencedContainer = "container:GiteaPR.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+8
View File
@@ -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 = ""
}
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+79
View File
@@ -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() }
}
+184
View File
@@ -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()
}
}
+151
View File
@@ -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
}
}
}
+135
View File
@@ -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() }
}
}
}
+258
View File
@@ -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"
}
}
+13
View File
@@ -0,0 +1,13 @@
import SwiftUI
@main
struct GiteaPRApp: App {
@StateObject private var settings = SettingsStore.shared
var body: some Scene {
WindowGroup {
InboxView()
.environmentObject(settings)
}
}
}
+121
View File
@@ -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
}
}
+46
View File
@@ -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"
}
}
+16
View File
@@ -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
}
}
+103
View File
@@ -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
}
}
+28
View File
@@ -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"
}
}
+32
View File
@@ -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."
}
}
}
+208
View File
@@ -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<T: Decodable>(_ 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<T: Decodable>(_ 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")
}
}
+50
View File
@@ -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)
}
}
+54
View File
@@ -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 = ""
}
}
+129
View File
@@ -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()
}
}
+52
View File
@@ -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
}
}
+122
View File
@@ -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())
}
}
+48
View File
@@ -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
}
}
}
+33
View File
@@ -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.