GiteaPR: native iOS app to manage Gitea pull requests
Build iOS App / Build .ipa and publish source (push) Failing after 35s
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:
@@ -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
@@ -0,0 +1,10 @@
|
||||
# Xcode
|
||||
build/
|
||||
DerivedData/
|
||||
*.ipa
|
||||
dist/
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct GiteaPRApp: App {
|
||||
@StateObject private var settings = SettingsStore.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
InboxView()
|
||||
.environmentObject(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user