Compare commits

...

20 commits
v1.5 ... main

Author SHA1 Message Date
Stefan Hausotte
3063eabfde chore: release 1.6 2026-06-15 19:06:42 +02:00
Stefan Hausotte
a8ce9d1180 ci: make release recipe sed portable across BSD and GNU sed (#83)
`just release` used `sed -i ''`, which is BSD/macOS syntax. In the nix dev
shell GNU sed shadows it and reads `''` as an empty script and the s/.../
expression as a filename, aborting the version bump. Use `-i.bak` plus a
cleanup `rm`, which works on both BSD and GNU sed.

Reviewed-on: https://codeberg.org/secana/Forji/pulls/83
2026-06-15 19:06:16 +02:00
Stefan Hausotte
e36150a2fc ci: add .envrc 2026-06-15 19:02:59 +02:00
Stefan Hausotte
c5893ff638 fix: clearer login error for security-key (passkey) accounts (#82)
Bump ForgejoKit to 0.8.1, which classifies Forgejo's "Basic authorization
is not allowed while having security keys enrolled" 401 as a dedicated
error instead of "Invalid username or password". Accounts with a passkey
or security key now get told to use a personal access token.

Add the new .basicAuthBlockedBySecurityKey case to the session-restore
error mapping (routed through the auth-category path like other 401s).

Closes #78

Reviewed-on: https://codeberg.org/secana/Forji/pulls/82
2026-06-15 18:49:46 +02:00
Stefan Hausotte
64145f50cc fix: make issue/PR description editable in edit form (#80) (#81)
The description row in DescriptionEditorSection is a Button whose label
renders a MarkdownPreview when a body exists. The preview enables text
selection and tappable links, which intercepted the tap inside the Button
label so the editor sheet never opened. Existing issues (with a body)
could therefore not have their description edited; only the empty 'None'
placeholder was tappable.

Disable hit testing on the preview so the tap falls through to the Button.
This fixes IssueEditView, PullRequestEditView, and the create flows, which
all share DescriptionEditorSection.

Extend IssueMutatingUITests to edit an issue description and verify it
persists, closing the coverage gap (the prior test only edited the title).

Closes #80

Reviewed-on: https://codeberg.org/secana/Forji/pulls/81
2026-06-15 14:20:24 +02:00
Stefan Hausotte
69f7923a52 chore: formatting and lints 2026-06-15 12:19:00 +02:00
Stefan Hausotte
a7491daffc feat: display and upload image attachments in issues, PRs, and comments
Adds `AttachmentGallery` to show image thumbnails and file rows on
issue/PR
detail views and inline in comment bodies. A `PhotosPicker` button in
the
markdown toolbar lets users attach images when composing or editing; the
selected image is uploaded via the Forgejo assets API and a markdown
reference is inserted into the editor.

Closes #79

test(ui): add AttachmentUITests — upload image and verify display

Adds a combined UI test that uploads a 1×1 PNG to an issue via the
markdown
toolbar, submits a comment, re-enters the issue, and asserts that the
Attachments section and gallery are visible.

- AttachmentGallery: add accessibilityIdentifier("attachment-gallery")
- MarkdownComponents: add -dev_testImageUpload launch-arg branch that
  bypasses PhotosPicker and uploads a hardcoded PNG, keeping the test
  deterministic without requiring photos in the simulator's library

test(attachment): fix gallery element query and image detection for
Forgejo uploads

Move the attachment-gallery accessibility identifier from the VStack
container
to the horizontal ScrollView, which XCTest reliably exposes as a scroll
view
element. Update the UI test to use
app.scrollViews["attachment-gallery"].

test(attachment): verify multiple image uploads render as distinct
thumbnails

Upload two images from the comment sheet, assert two markdown references
are
inserted, and after reload assert both render as thumbnails in the
gallery.
Confirms the ForEach(imageAttachments) gallery handles multiple
attachments.

test: add unit tests for attachmentMarkdown image vs link syntax

Cover the pure attachmentMarkdown(for:) function directly: images (by
MIME
type and by extension) produce embed syntax, non-image files (log, pdf)
produce link syntax. Faster and more focused than the UI test, which
only
exercised this indirectly.

feat: wrap markdown toolbar icons and add file attachment upload

The markdown toolbar used a horizontal scroll view, so trailing icons
(link,
list, quote, task, attach) were hidden off-screen with no visual cue.
Replace
it with a wrapping flow layout (MarkdownToolbarFlow) so every icon is
always
visible, wrapping to a second row on narrow widths.

Add general file-attachment upload: the attach button is now a menu
offering
"Photo Library" (images via PhotosPicker) and "Choose File" (any file
via
.fileImporter). Picked files are read through a security-scoped resource
and
uploaded with a MIME type derived from the extension; non-image files
insert
link markdown via the existing attachmentMarkdown(for:) path.
2026-06-15 09:52:26 +02:00
Stefan Hausotte
de5cb025b8 fix: show graceful empty state when repository has pull requests turned off (#77)
Reviewed-on: https://codeberg.org/secana/Forji/pulls/77
2026-06-13 14:12:38 +02:00
systemblue
100d7c412c fix: de-duplicate involved-scope results across pages in single-instance overviews (#70)
**Why.** The Issues and Pull Requests overviews fire one search request per involvement flag (created/assigned/mentioned/review-requested) and merge the results, so the same issue can resurface on a later page — created on page 1, assigned on page 2. The merged multi-instance overview already de-duplicates across pages with a pagination dedupe key, but the single-instance overview never set one, so loading more appended duplicate rows with duplicate Identifiable ids.

**What changed.** `SearchableOverviewView.init` sets the same `dedupeKey` the merged overview sets (`MergedSearchableOverviewView` line 71), routing pages through `PaginationState`'s existing cross-page dedupe. The `dedupeKey` doc comment is updated to match. `hasMore` switches to the contributed-new-items heuristic, which fits combined per-flag sources whose merged page size never matches the request limit.

**Verification.** New `PaginationStateDedupeTests` pin the keyed path: a key repeated across pages is filtered out on the later page, `hasMore` follows `!newItems.isEmpty` when `dedupeKey` is set (including a full page of already-seen keys ending pagination), and a reload resets the seen keys. Full `ForjiTests` suite passes (0 failed, iPhone 17 Pro, iOS 26.5). SwiftLint clean with the repo config.

I grant Stefan Hausotte an irrevocable, worldwide, royalty-free license to use, sublicense, and distribute my contribution, including through Apple's App Store under the project's App Store exception.

Reviewed-on: https://codeberg.org/secana/Forji/pulls/70
2026-06-13 14:01:56 +02:00
secana
617e687e7b fix: show graceful empty state when repository has issues turned off 2026-06-11 21:58:21 +02:00
systemblue
6c053741c5 fix: render 3-digit shorthand label colors instead of falling back to gray 2026-06-11 21:47:08 +02:00
systemblue
c82dedf957 test(issues): add preview for notFound state and extend PaginationState debug init
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:07:52 -04:00
systemblue
f39e9f682d fix: show graceful empty state when repository has issues turned off
When the Forgejo API returns 404 for the issues endpoint (issues
disabled for the repository), display a ContentUnavailableView instead
of surfacing the raw HTTP error alert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:56:23 -04:00
systemblue
f8a050e484 fix: refresh the merged notifications list when a push notification is opened (#71)
**Why.** Tapping a push notification bumps `NavigationState.notificationsRefreshTrigger` so the notifications tab reloads on arrival. The single-instance `NotificationsOverviewView` observes the trigger; the merged multi-instance overview never did, so multi-instance users landed on a stale list that still showed the tapped thread as unread until a manual pull-to-refresh.

**What changed.** `MergedNotificationsOverviewView` observes the same trigger with the same `onChange` the single-instance view uses, and reloads.

**Verification.** Full `ForjiTests` suite passes (218 passed / 0 failed, iPhone 17 Pro, iOS 26.5, rebased onto current main). SwiftLint and SwiftFormat clean. The push-open path needs a real APNs round trip, so I couldn't exercise it end-to-end locally; the change mirrors the single-instance wiring line for line.

I grant Stefan Hausotte an irrevocable, worldwide, royalty-free license to use, sublicense, and distribute my contribution, including through Apple's App Store under the project's App Store exception.

Reviewed-on: https://codeberg.org/secana/Forji/pulls/71
2026-06-10 20:17:57 +02:00
Stefan Hausotte
2a679140e6 test: stream seed progress and add a docker exec timeout (#69)
The integration seeder gave no visible progress and could hang
indefinitely. A `docker compose exec` for the admin-user step wedged on
a transient Docker-on-macOS flake, and `DockerExec.run` waited on it
with no timeout, blocking the whole UI test suite forever with no output
(stdout was block-buffered, so the existing phase prints never flushed).

- Line-buffer the seeder's stdout so phase progress streams live instead
  of being dumped all at once when stdout is a pipe.
- Add numbered "[n/7] <phase> (Ns elapsed)" headers for a clear progress
  and timing signal.
- Add a 60s timeout to `docker compose exec` and retry the admin-user
  step, so a hung exec fails fast and recovers instead of wedging the
  suite.

Reviewed-on: https://codeberg.org/secana/Forji/pulls/69
2026-06-09 12:55:45 +02:00
systemBlue
b1942df58d feat: compact notification rows (#55)
## What

Notification rows now put the repository name and timestamp on one line ("repo · time") instead of two stacked caption lines, so the list scans like Mail and fits more per screen. The unread dot drops its `glassEffect` for a plain filled circle (cleaner, and avoids GPU glass compositing on a 10pt element inside a scrolling list).

Before: title / repo / time on three lines.
After: title, then "repo · time", with the repo truncating in the middle and the timestamp fixed-width.

Verified: builds clean (Xcode 26, iPhone 17 Pro simulator), SwiftLint passes, confirmed in the simulator with preview data.

---

I grant Stefan Hausotte an irrevocable, worldwide, royalty-free license to use, sublicense, and distribute my contribution, including through Apple's App Store under the project's App Store exception.

Reviewed-on: https://codeberg.org/secana/Forji/pulls/55
2026-06-09 12:21:05 +02:00
systemBlue
78c91cd2a9 feat: show the overview filter summary only when filters are active (#60)
## What

The Issues and Pull Requests overviews pinned a centered filter-summary caption ("Open") below the title even in the default state, which just duplicated the filter icon. Now the summary appears only when a non-default state or involvement scope is active, so the default view carries no extra chrome and the strip becomes a clear signal that the list is filtered.

Verified in the iPhone 17 Pro simulator: default state shows no strip and no gap, and the filter icon still reflects state. Builds clean on Xcode 26, SwiftLint passes.

---

I grant Stefan Hausotte an irrevocable, worldwide, royalty-free license to use, sublicense, and distribute my contribution, including through Apple's App Store under the project's App Store exception.

Reviewed-on: https://codeberg.org/secana/Forji/pulls/60
2026-06-09 12:18:44 +02:00
systemBlue
ab393d0237 feat: shorten the Pull Requests tab to PRs in repository detail (#59)
## What

The repository detail's segmented control has four segments (Code, Issues, Pull Requests, Actions). "Pull Requests" dominated the width, squeezed "Code", and truncated on narrower iPhones. Shortening it to "PRs" balances the segments and keeps them legible.

Verified in the iPhone 17 Pro simulator (segments now even); builds clean on Xcode 26, SwiftLint passes.

---

I grant Stefan Hausotte an irrevocable, worldwide, royalty-free license to use, sublicense, and distribute my contribution, including through Apple's App Store under the project's App Store exception.

Reviewed-on: https://codeberg.org/secana/Forji/pulls/59
2026-06-09 12:15:52 +02:00
Stefan Hausotte
d580156fa2 ci: add automatic changelog creation 2026-06-04 15:03:47 +02:00
Stefan Hausotte
ad35a6f4f7 docs: add a CHANGELOG.md file
Add a CHANGELOG.md file that uses the "keep a changelog" convention to
make changes to Forji more transparent
2026-06-04 14:44:51 +02:00
61 changed files with 1277 additions and 221 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

View file

@ -1,2 +1,3 @@
--swiftversion 6.2
--disable redundantMemberwiseInit
--disable swiftTestingTestCaseNames

65
CHANGELOG.md Normal file
View file

@ -0,0 +1,65 @@
# Changelog
All notable changes to Forji will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.6] - 2026-06-15
### Added
- Shorten the Pull Requests tab to PRs in repository detail (#59)
- Show the overview filter summary only when filters are active (#60)
- Compact notification rows (#55)
- Display and upload image attachments in issues, PRs, and comments
### Changed
- Add a CHANGELOG.md file
### Fixed
- Refresh the merged notifications list when a push notification is opened (#71)
- Render 3-digit shorthand label colors instead of falling back to gray
- Show graceful empty state when repository has issues turned off
- Show graceful empty state when repository has issues turned off
- De-duplicate involved-scope results across pages in single-instance overviews (#70)
- Show graceful empty state when repository has pull requests turned off (#77)
- Make issue/PR description editable in edit form (#80) (#81)
- Clearer login error for security-key (passkey) accounts (#82)
## [1.5] - 2026-06-04
### Added
- VoiceOver accessibility labels for icon-only buttons (#49).
- Tappable diff lines now act as buttons for VoiceOver (#61).
- `CONTRIBUTING.md` (#42).
- `just sim-update` command for updating the simulator.
### Changed
- Updated ForgejoKit dependency from 0.6.0 to 0.7.0 (via 0.6.1).
- Refactored error handling to use ForgejoKit error categories (#31).
- Fixed a typo in the README App Store line (#43).
### Removed
- Redundant notification "Dismiss" swipe action (#46).
### Fixed
- Crash in merged overviews when two accounts shared the same `sourceKey` (#39).
- Merged pagination now settles correctly and no longer shows duplicate rows (#47).
- Merged issue/PR overview now refreshes after a mutation (#56).
- App icon badge now updates in multi-instance mode (#58).
- Multi-instance fallback is now keyed by account (#29).
- Removing an account now deletes the API token, not just the password (#40).
- Instance removal is now persisted before deleting the keychain on logout (#48).
- Added a guard that prevents editing an account into a duplicate of an existing one.
- Token restore error context is now preserved (#30).
- Issue/PR detail now refreshes after a partial edit failure (#50).
- No longer opens a duplicate PR when the reviewer request fails (#53).
- Background notification poll no longer crashes on an invalid instance URL (#54).
- Messages that were not marked as read are now handled correctly.

View file

@ -438,7 +438,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.5;
CURRENT_PROJECT_VERSION = 1.6;
DEVELOPMENT_TEAM = RVT2M7QTD4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -454,7 +454,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.Forji;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@ -474,7 +474,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.5;
CURRENT_PROJECT_VERSION = 1.6;
DEVELOPMENT_TEAM = RVT2M7QTD4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -490,7 +490,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.Forji;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@ -509,11 +509,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.5;
CURRENT_PROJECT_VERSION = 1.6;
DEVELOPMENT_TEAM = RVT2M7QTD4;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.ForjiTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@ -532,11 +532,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.5;
CURRENT_PROJECT_VERSION = 1.6;
DEVELOPMENT_TEAM = RVT2M7QTD4;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.ForjiTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@ -554,10 +554,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.5;
CURRENT_PROJECT_VERSION = 1.6;
DEVELOPMENT_TEAM = RVT2M7QTD4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.ForjiUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@ -575,10 +575,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.5;
CURRENT_PROJECT_VERSION = 1.6;
DEVELOPMENT_TEAM = RVT2M7QTD4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.ForjiUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@ -639,7 +639,7 @@
repositoryURL = "https://codeberg.org/secana/ForgejoKit.git";
requirement = {
kind = exactVersion;
version = 0.7.0;
version = 0.8.1;
};
};
DEC49F6B2F3D00C700E7DD54 /* XCRemoteSwiftPackageReference "textual" */ = {

View file

@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://codeberg.org/secana/ForgejoKit.git",
"state" : {
"revision" : "4d1dfc1305c0194fc74b09d15d29940a98cd9752",
"version" : "0.7.0"
"revision" : "7675312f5ca302968e267aad632bec4c76414e06",
"version" : "0.8.1"
}
},
{

View file

@ -150,7 +150,7 @@ struct ContentView: View {
defaultInstance.lastUsed = Date()
try? modelContext.save()
} catch {
// Auto-login failed fall through to instance list
// Auto-login failed, fall through to instance list
}
}

View file

@ -152,7 +152,7 @@ enum HTMLParser {
}
}
// 2. Inline HTML check paragraphs for inline tags
// 2. Inline HTML, check paragraphs for inline tags
if let inlinePattern = inlineHTMLPattern {
let paragraphs = splitIntoParagraphs(masked)
for para in paragraphs {

View file

@ -9,16 +9,17 @@ final class PaginationState<Item> {
private(set) var hasLoaded = false
var errorMessage: String?
var showError = false
var notFound = false
private var currentPage = 1
private var loadTask: Task<Void, Never>?
let pageSize: Int
var cacheName: String?
/// Optional key extractor enabling cross-page de-duplication. Merged views set
/// this because they combine paginated results from several instances, where
/// the same item can resurface on a later page (overlapping involvement
/// queries) and the merged page size no longer matches `pageSize`.
/// Optional key extractor enabling cross-page de-duplication. Views that
/// combine several paginated sources set this (merged multi-instance lists,
/// or the per-flag involvement queries), because the same item can resurface
/// on a later page and the combined page size no longer matches `pageSize`.
@ObservationIgnored var dedupeKey: ((Item) -> AnyHashable)?
@ObservationIgnored private var seenKeys = Set<AnyHashable>()
@ -26,10 +27,11 @@ final class PaginationState<Item> {
self.pageSize = pageSize
}
init(items: [Item], hasMore: Bool = false, pageSize: Int = 20) {
init(items: [Item], hasMore: Bool = false, pageSize: Int = 20, notFound: Bool = false) {
self.items = items
self.hasMore = hasMore
self.pageSize = pageSize
self.notFound = notFound
}
@discardableResult
@ -44,6 +46,7 @@ final class PaginationState<Item> {
hasLoaded = false
isLoading = true
showError = false
notFound = false
let pageSize = pageSize
let task = Task {
do {

View file

@ -115,6 +115,28 @@ import Foundation
)
}
extension Attachment {
static let previewImage = Attachment(
id: 1,
uuid: "ee01801f-ffdb-4154-856b-77eab52aaad2",
name: "screenshot.png",
size: 222_208,
browserDownloadUrl: "https://codeberg.org/attachments/ee01801f-ffdb-4154-856b-77eab52aaad2",
type: "image/png",
created: Date(),
)
static let previewFile = Attachment(
id: 2,
uuid: "abc12345-0000-0000-0000-000000000000",
name: "report.pdf",
size: 51200,
browserDownloadUrl: "https://codeberg.org/attachments/abc12345-0000-0000-0000-000000000000",
type: "application/pdf",
created: Date(),
)
}
extension IssueComment {
static let preview = IssueComment(
id: 1,

View file

@ -36,7 +36,7 @@ struct WorkflowStatusStyle {
let label: String
/// Maps Forgejo's single status value to a UI style. Used for runs, jobs,
/// and steps they share the same status vocabulary. Forgejo's known
/// and steps, they share the same status vocabulary. Forgejo's known
/// statuses: success, failure, cancelled, skipped, running, waiting,
/// blocked, unknown. Anything else falls back to a question-mark glyph.
static func forStatus(_ status: String) -> WorkflowStatusStyle {
@ -122,7 +122,7 @@ extension WorkflowRun {
}
/// Elapsed time as TimeInterval. Forgejo serialises a Go `time.Duration`
/// int64 nanoseconds so divide by 1_000_000_000. Falls back to the
/// , int64 nanoseconds, so divide by 1_000_000_000. Falls back to the
/// timestamps only when both are sane (post-2000).
var duration: TimeInterval? {
if let nanos = durationNanos, nanos > 0 {

View file

@ -116,7 +116,7 @@ class AuthenticationService {
username: instance.username,
)
} catch {
// Keychain delete failed log in debug builds
// Keychain delete failed, log in debug builds
#if DEBUG
print("Keychain delete failed during logout: \(error)")
#endif
@ -171,7 +171,7 @@ class AuthenticationService {
isAuthenticated = true
return
} catch {
// Token is invalid/expired only fall through to password if this is not a token-only instance
// Token is invalid/expired, only fall through to password if this is not a token-only instance
if useTokenAuth {
throw SessionRestoreError.fromTokenValidationError(error)
}
@ -269,7 +269,7 @@ enum SessionRestoreError: LocalizedError, Equatable {
.invalidServerResponse
case .invalidURL:
.serverNotFound
case .invalidCredentials, .otpRequired, .serverNotFound, .unknownError:
case .invalidCredentials, .otpRequired, .basicAuthBlockedBySecurityKey, .serverNotFound, .unknownError:
fromHTTPErrorCategory(error.httpErrorCategory, statusCode: error.httpStatusCode)
}
}

View file

@ -0,0 +1,122 @@
import ForgejoKit
import SwiftUI
/// Markdown to insert when an attachment is uploaded. Images use embed syntax
/// (`![name](url)`) so they render inline; every other file type (logs, PDFs,
/// archives, and so on) uses link syntax (`[name](url)`) so it appears as a
/// clickable link rather than a broken inline image.
func attachmentMarkdown(for attachment: Attachment) -> String {
if attachment.isImage {
"![\(attachment.name)](\(attachment.browserDownloadUrl))"
} else {
"[\(attachment.name)](\(attachment.browserDownloadUrl))"
}
}
struct AttachmentGallery: View {
let attachments: [Attachment]
private var imageAttachments: [Attachment] {
attachments.filter(\.isImage)
}
private var fileAttachments: [Attachment] {
attachments.filter { !$0.isImage }
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if !imageAttachments.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(imageAttachments) { attachment in
imageThumb(attachment)
}
}
}
.accessibilityIdentifier("attachment-gallery")
}
ForEach(fileAttachments) { attachment in
fileRow(attachment)
}
}
}
private func imageThumb(_ attachment: Attachment) -> some View {
Group {
if let url = URL(string: attachment.browserDownloadUrl) {
Link(destination: url) {
AsyncImage(url: url) { phase in
switch phase {
case let .success(image):
image
.resizable()
.scaledToFill()
case .failure:
brokenImagePlaceholder
default:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(width: 120, height: 90)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
private func fileRow(_ attachment: Attachment) -> some View {
Group {
if let url = URL(string: attachment.browserDownloadUrl) {
Link(destination: url) {
HStack(spacing: 8) {
Image(systemName: "paperclip")
.foregroundStyle(.secondary)
Text(attachment.name)
.font(.subheadline)
Spacer()
Text(formatFileSize(attachment.size))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.tint(.primary)
.accessibilityIdentifier("attachment-file-row")
}
}
}
private var brokenImagePlaceholder: some View {
Image(systemName: "photo.badge.exclamationmark")
.font(.title2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.tertiarySystemFill))
}
private func formatFileSize(_ bytes: Int) -> String {
let kilobytes = Double(bytes) / 1024
if kilobytes < 1024 {
return String(format: "%.1f KB", kilobytes)
}
return String(format: "%.1f MB", kilobytes / 1024)
}
}
#if DEBUG
#Preview("Images") {
List {
Section("Attachments") {
AttachmentGallery(attachments: [.previewImage, .previewFile])
}
}
.listStyle(.insetGrouped)
}
#Preview("Empty") {
AttachmentGallery(attachments: [])
}
#endif

View file

@ -3,6 +3,7 @@ import SwiftUI
struct CommentSheet: View {
let users: [User]
var onUploadImage: ((Data, String, String) async throws -> String)?
var onSubmit: (String) async throws -> Void
@State private var text = ""
@ -20,6 +21,7 @@ struct CommentSheet: View {
text: $text,
selectedTab: $selectedTab,
showToolbar: true,
onUploadImage: onUploadImage,
)
} else {
MentionableEditorField(
@ -27,6 +29,7 @@ struct CommentSheet: View {
selectedTab: $selectedTab,
users: users,
showToolbar: true,
onUploadImage: onUploadImage,
)
}
}

View file

@ -41,6 +41,11 @@ struct CommentView: View {
}
MarkdownPreview(text: displayBody)
if let assets = comment.assets, !assets.isEmpty {
AttachmentGallery(attachments: assets)
.padding(.top, 4)
}
}
.padding(.vertical, 4)
.onAppear {

View file

@ -146,7 +146,7 @@ struct CommitHistoryView: View {
repo: repo,
)
} catch {
// Non-critical branch selector stays disabled
// Non-critical, branch selector stays disabled
}
}

View file

@ -100,7 +100,7 @@ struct HomeView: View {
do {
unreadCount = try await notificationService.fetchUnreadCount()
} catch {
// Silently ignore badge is non-critical, keep the prior count
// Silently ignore, badge is non-critical, keep the prior count
return
}
} else {

View file

@ -117,6 +117,14 @@ struct IssueDetailView: View {
}
}
// Attachments
if let assets = issue.assets, !assets.isEmpty {
Section("Attachments") {
AttachmentGallery(attachments: assets)
.padding(.vertical, 4)
}
}
// Comments
if !comments.isEmpty {
Section("Comments (\(comments.count))") {
@ -187,13 +195,14 @@ struct IssueDetailView: View {
repository: repository,
issue: issue,
authService: authService,
onUploadImage: uploadIssueImage,
) { updatedIssue in
self.issue = updatedIssue
}
}
}
.sheet(isPresented: $showCommentSheet) {
CommentSheet(users: []) { body in
CommentSheet(users: [], onUploadImage: uploadIssueImage) { body in
guard let issueService else { throw URLError(.userAuthenticationRequired) }
let comment = try await issueService.createComment(
owner: owner,
@ -210,6 +219,15 @@ struct IssueDetailView: View {
.errorAlert(message: $errorMessage, isPresented: $showError)
}
private func uploadIssueImage(data: Data, fileName: String, mimeType: String) async throws -> String {
guard let issueService else { throw URLError(.userAuthenticationRequired) }
let attachment = try await issueService.uploadIssueAttachment(
owner: owner, repo: repo, index: issueNumber,
fileData: data, fileName: fileName, mimeType: mimeType,
)
return attachmentMarkdown(for: attachment)
}
private func loadData() async {
guard let issueService else { return }
isLoading = true

View file

@ -21,14 +21,20 @@ struct IssueEditView: View {
private let issueService: IssueService?
private let repositoryService: RepositoryService?
private let onUploadImage: ((Data, String, String) async throws -> String)?
private let onSaved: (Issue) -> Void
init(repository: Repository, issue: Issue, authService: AuthenticationService, onSaved: @escaping (Issue) -> Void) {
init(
repository: Repository, issue: Issue, authService: AuthenticationService,
onUploadImage: ((Data, String, String) async throws -> String)? = nil,
onSaved: @escaping (Issue) -> Void,
) {
self.repository = repository
self.issue = issue
self.authService = authService
issueService = authService.client.map { IssueService(client: $0) }
repositoryService = authService.client.map { RepositoryService(client: $0) }
self.onUploadImage = onUploadImage
self.onSaved = onSaved
_title = State(initialValue: issue.title)
_bodyText = State(initialValue: issue.body ?? "")
@ -54,7 +60,7 @@ struct IssueEditView: View {
.accessibilityIdentifier("issue-edit-title-field")
}
DescriptionEditorSection(text: $bodyText)
DescriptionEditorSection(text: $bodyText, onUploadImage: onUploadImage)
LabelPickerSection(
availableLabels: availableLabels,
@ -151,6 +157,7 @@ struct IssueEditView: View {
self.authService = authService
issueService = nil
repositoryService = nil
onUploadImage = nil
onSaved = { _ in }
_title = State(initialValue: issue.title)
_bodyText = State(initialValue: issue.body ?? "")

View file

@ -19,10 +19,7 @@ struct IssueLabelView: View {
}
private var textColor: Color {
let hex = label.color.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
guard hex.count == 6,
let rgb = UInt64(hex, radix: 16)
else {
guard let rgb = Color.rgbValue(hex: label.color) else {
return .white
}
let red = Double((rgb >> 16) & 0xFF) / 255.0
@ -35,10 +32,7 @@ struct IssueLabelView: View {
extension Color {
init?(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
guard hex.count == 6,
let rgb = UInt64(hex, radix: 16)
else {
guard let rgb = Color.rgbValue(hex: hex) else {
return nil
}
self.init(
@ -47,6 +41,21 @@ extension Color {
blue: Double(rgb & 0xFF) / 255.0,
)
}
/// Parses a 6-digit or 3-digit shorthand hex color into its RGB value,
/// expanding the shorthand the same way Forgejo normalizes label colors
/// (#f00 -> #ff0000). Labels created before Forgejo normalized colors on
/// write can still carry the shorthand form.
static func rgbValue(hex: String) -> UInt64? {
var hex = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
if hex.count == 3 {
hex = hex.map { "\($0)\($0)" }.joined()
}
guard hex.count == 6 else {
return nil
}
return UInt64(hex, radix: 16)
}
}
#if DEBUG

View file

@ -38,6 +38,12 @@ struct IssueListView: View {
List {
if pagination.isLoading, pagination.items.isEmpty {
LoadingListSection()
} else if pagination.notFound {
ContentUnavailableView {
Label("Issues Unavailable", systemImage: "exclamationmark.circle")
} description: {
Text("Issues are not available for this repository.")
}
} else if pagination.items.isEmpty {
ContentUnavailableView {
Label(
@ -49,7 +55,7 @@ struct IssueListView: View {
} description: {
Text(
stateFilter == .open
? "All clear no open issues to review."
? "All clear, no open issues to review."
: "No closed issues found.",
)
}
@ -116,13 +122,18 @@ struct IssueListView: View {
private func reloadIssues(clearItems: Bool = false) -> Task<Void, Never> {
guard let issueService else { return Task {} }
return pagination.reload(clearItems: clearItems) { [self] page, limit in
try await issueService.fetchIssues(
owner: owner,
repo: repo,
state: stateFilter.rawValue,
page: page,
limit: limit,
)
do {
return try await issueService.fetchIssues(
owner: owner,
repo: repo,
state: stateFilter.rawValue,
page: page,
limit: limit,
)
} catch let error as ServiceError where error.httpStatusCode == 404 {
pagination.notFound = true
return []
}
}
}
@ -140,11 +151,13 @@ struct IssueListView: View {
}
#if DEBUG
init(preview _: Void, repository: Repository, authService: AuthenticationService, issues: [Issue]) {
init(preview _: Void, repository: Repository, authService: AuthenticationService, issues: [Issue],
notFound: Bool = false)
{
self.repository = repository
self.authService = authService
issueService = nil
_pagination = State(initialValue: PaginationState(items: issues))
_pagination = State(initialValue: PaginationState(items: issues, notFound: notFound))
}
#endif
}
@ -160,6 +173,18 @@ struct IssueListView: View {
)
}
}
#Preview("Issues Unavailable") {
NavigationStack {
IssueListView(
preview: (),
repository: .preview,
authService: .previewDefault,
issues: [],
notFound: true,
)
}
}
#endif
struct IssueRow: View {

View file

@ -1,6 +1,8 @@
import ForgejoKit
import PhotosUI
import SwiftUI
import Textual
import UniformTypeIdentifiers
enum EditPreviewTab: String, CaseIterable {
case edit = "Edit"
@ -12,6 +14,9 @@ struct MarkdownEditorField: View {
@Binding var selectedTab: EditPreviewTab
var minHeight: CGFloat = 150
var showToolbar: Bool = false
/// Called with (data, fileName, mimeType) when the user picks an image.
/// Return the markdown snippet to insert, e.g. `![name](url)`.
var onUploadImage: ((Data, String, String) async throws -> String)?
var body: some View {
VStack(spacing: 0) {
@ -26,7 +31,7 @@ struct MarkdownEditorField: View {
if selectedTab == .edit {
VStack(spacing: 0) {
if showToolbar {
MarkdownToolbar(text: $text)
MarkdownToolbar(text: $text, onUploadImage: onUploadImage)
}
TextEditor(text: $text)
.frame(minHeight: minHeight, maxHeight: .infinity)
@ -58,66 +63,6 @@ struct MarkdownEditorField: View {
}
}
private struct MarkdownToolbar: View {
@Binding var text: String
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
toolbarButton("bold", icon: "bold", label: "Bold") { wrap("**") }
toolbarButton("italic", icon: "italic", label: "Italic") { wrap("_") }
toolbarButton("heading", icon: "number", label: "Heading") { prefix("# ") }
toolbarButton("code", icon: "chevron.left.forwardslash.chevron.right",
label: "Inline code") { wrap("`") }
toolbarButton("codeblock", icon: "text.page", label: "Code block") { wrapBlock("```") }
toolbarButton("link", icon: "link", label: "Insert link") { insertLink() }
toolbarButton("list", icon: "list.bullet", label: "Bulleted list") { prefix("- ") }
toolbarButton("quote", icon: "text.quote", label: "Quote") { prefix("> ") }
toolbarButton("task", icon: "checklist", label: "Task list") { prefix("- [ ] ") }
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
}
private func toolbarButton(_ id: String, icon: String, label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.subheadline)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.accessibilityLabel(label)
.accessibilityIdentifier("markdown-toolbar-\(id)")
}
private func wrap(_ marker: String) {
text.append("\(marker)text\(marker)")
}
private func prefix(_ marker: String) {
if text.isEmpty || text.hasSuffix("\n") {
text.append(marker)
} else {
text.append("\n\(marker)")
}
}
private func wrapBlock(_ fence: String) {
if text.isEmpty || text.hasSuffix("\n") {
text.append("\(fence)\n\n\(fence)")
} else {
text.append("\n\(fence)\n\n\(fence)")
}
}
private func insertLink() {
text.append("[title](url)")
}
}
struct MarkdownPreview: View {
let text: String
var baseURL: URL?

View file

@ -0,0 +1,215 @@
import Foundation
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers
struct MarkdownToolbar: View {
@Binding var text: String
var onUploadImage: ((Data, String, String) async throws -> String)?
@State private var photosItem: PhotosPickerItem?
@State private var showPhotosPicker = false
@State private var showFileImporter = false
@State private var isUploading = false
@State private var uploadError: String?
@State private var showUploadError = false
var body: some View {
// A wrapping flow layout keeps every icon visible: when they don't fit on
// one line they wrap to the next, instead of being hidden off-screen behind
// a horizontal scroll view.
MarkdownToolbarFlow(spacing: 10) {
toolbarButton("bold", icon: "bold", label: "Bold") { wrap("**") }
toolbarButton("italic", icon: "italic", label: "Italic") { wrap("_") }
toolbarButton("heading", icon: "number", label: "Heading") { prefix("# ") }
toolbarButton("code", icon: "chevron.left.forwardslash.chevron.right",
label: "Inline code") { wrap("`") }
toolbarButton("codeblock", icon: "text.page", label: "Code block") { wrapBlock("```") }
toolbarButton("link", icon: "link", label: "Insert link") { insertLink() }
toolbarButton("list", icon: "list.bullet", label: "Bulleted list") { prefix("- ") }
toolbarButton("quote", icon: "text.quote", label: "Quote") { prefix("> ") }
toolbarButton("task", icon: "checklist", label: "Task list") { prefix("- [ ] ") }
if onUploadImage != nil {
uploadControl
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.alert("Upload Failed", isPresented: $showUploadError) {
Button("OK", role: .cancel) {}
} message: {
Text(uploadError ?? "")
}
}
@ViewBuilder
private var uploadControl: some View {
if isUploading {
ProgressView()
.controlSize(.small)
.frame(width: 28, height: 28)
} else if ProcessInfo.processInfo.arguments.contains("-dev_testImageUpload") {
// In UI tests, bypass the pickers and upload hardcoded files directly.
Button { Task { await uploadTestImage() } } label: {
toolbarIcon("photo")
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.accessibilityLabel("Attach image")
.accessibilityIdentifier("markdown-toolbar-image")
Button { Task { await uploadTestFile() } } label: {
toolbarIcon("doc")
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.accessibilityLabel("Attach file")
.accessibilityIdentifier("markdown-toolbar-file")
} else {
Menu {
Button { showPhotosPicker = true } label: {
Label("Photo Library", systemImage: "photo")
}
Button { showFileImporter = true } label: {
Label("Choose File", systemImage: "doc")
}
} label: {
toolbarIcon("paperclip")
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.accessibilityLabel("Attach")
.accessibilityIdentifier("markdown-toolbar-attach")
.photosPicker(isPresented: $showPhotosPicker, selection: $photosItem, matching: .images)
.onChange(of: photosItem) { _, item in
guard let item else { return }
Task { await uploadPickedImage(item) }
}
.fileImporter(
isPresented: $showFileImporter,
allowedContentTypes: [.item],
allowsMultipleSelection: false,
) { result in
Task { await uploadPickedFile(result) }
}
}
}
private func toolbarIcon(_ systemName: String) -> some View {
Image(systemName: systemName)
.font(.subheadline)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
private func uploadTestImage() async {
guard let onUploadImage else { return }
// 1×1 transparent PNG, used in UI tests to skip the Photos picker.
let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
guard let pngData = Data(base64Encoded: base64) else { return }
await runUpload { try await onUploadImage(pngData, "test.png", "image/png") }
}
private func uploadTestFile() async {
guard let onUploadImage else { return }
// Plain-text log file, used in UI tests to exercise the non-image
// attachment path (link markdown + file-row display).
let logData = Data("line1 ERROR boom\nline2 WARN stuff\nline3 done\n".utf8)
await runUpload { try await onUploadImage(logData, "server.log", "text/plain") }
}
private func runUpload(_ upload: () async throws -> String) async {
isUploading = true
defer { isUploading = false }
do {
let markdown = try await upload()
if text.isEmpty || text.hasSuffix("\n") {
text.append(markdown)
} else {
text.append("\n\(markdown)")
}
} catch {
uploadError = error.localizedDescription
showUploadError = true
}
}
private func uploadPickedFile(_ result: Result<[URL], Error>) async {
guard let onUploadImage else { return }
do {
let urls = try result.get()
guard let url = urls.first else { return }
// Files returned by the document picker live outside the app sandbox and
// must be accessed through a security-scoped resource.
let didAccess = url.startAccessingSecurityScopedResource()
defer { if didAccess { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
let fileName = url.lastPathComponent
let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType
?? "application/octet-stream"
await runUpload { try await onUploadImage(data, fileName, mimeType) }
} catch {
uploadError = error.localizedDescription
showUploadError = true
}
}
private func uploadPickedImage(_ item: PhotosPickerItem) async {
guard let onUploadImage else { return }
isUploading = true
defer {
isUploading = false
photosItem = nil
}
do {
guard let data = try await item.loadTransferable(type: Data.self) else { return }
let mimeType = item.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg"
let ext = item.supportedContentTypes.first?.preferredFilenameExtension ?? "jpg"
let markdown = try await onUploadImage(data, "image.\(ext)", mimeType)
if text.isEmpty || text.hasSuffix("\n") {
text.append(markdown)
} else {
text.append("\n\(markdown)")
}
} catch {
uploadError = error.localizedDescription
showUploadError = true
}
}
private func toolbarButton(_ id: String, icon: String, label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
toolbarIcon(icon)
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.accessibilityLabel(label)
.accessibilityIdentifier("markdown-toolbar-\(id)")
}
private func wrap(_ marker: String) {
text.append("\(marker)text\(marker)")
}
private func prefix(_ marker: String) {
if text.isEmpty || text.hasSuffix("\n") {
text.append(marker)
} else {
text.append("\n\(marker)")
}
}
private func wrapBlock(_ fence: String) {
if text.isEmpty || text.hasSuffix("\n") {
text.append("\(fence)\n\n\(fence)")
} else {
text.append("\n\(fence)\n\n\(fence)")
}
}
private func insertLink() {
text.append("[title](url)")
}
}

View file

@ -0,0 +1,47 @@
import SwiftUI
/// A simple left-aligned flow layout: lays subviews out left to right, wrapping
/// to a new line when the next subview would overflow the available width. Used
/// by the markdown toolbar so all action icons stay visible (wrapping instead of
/// scrolling off-screen).
struct MarkdownToolbarFlow: Layout {
var spacing: CGFloat = 10
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout Void) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var cursorX: CGFloat = 0
var cursorY: CGFloat = 0
var rowHeight: CGFloat = 0
var widestRow: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if cursorX > 0, cursorX + size.width > maxWidth {
widestRow = max(widestRow, cursorX - spacing)
cursorX = 0
cursorY += rowHeight + spacing
rowHeight = 0
}
cursorX += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
widestRow = max(widestRow, cursorX - spacing)
return CGSize(width: min(maxWidth, widestRow), height: cursorY + rowHeight)
}
func placeSubviews(in bounds: CGRect, proposal _: ProposedViewSize, subviews: Subviews, cache _: inout Void) {
var cursorX = bounds.minX
var cursorY = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if cursorX > bounds.minX, cursorX + size.width > bounds.maxX {
cursorX = bounds.minX
cursorY += rowHeight + spacing
rowHeight = 0
}
subview.place(at: CGPoint(x: cursorX, y: cursorY), anchor: .topLeading, proposal: ProposedViewSize(size))
cursorX += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}

View file

@ -7,6 +7,7 @@ struct MentionableEditorField: View {
var users: [User]
var minHeight: CGFloat = 150
var showToolbar: Bool = false
var onUploadImage: ((Data, String, String) async throws -> String)?
@State private var mentionQuery: String?
@ -24,6 +25,7 @@ struct MentionableEditorField: View {
selectedTab: $selectedTab,
minHeight: minHeight,
showToolbar: showToolbar,
onUploadImage: onUploadImage,
)
if selectedTab == .edit, mentionQuery != nil, !matchingUsers.isEmpty {

View file

@ -6,6 +6,7 @@ struct MergedNotificationsOverviewView: View {
@State private var pagination: PaginationState<TaggedItem<NotificationThread>>
@State private var statusFilter: String = "unread"
@Environment(NavigationState.self) private var navigationState
init(manager: MultiInstanceManager) {
self.manager = manager
@ -95,6 +96,9 @@ struct MergedNotificationsOverviewView: View {
.onChange(of: statusFilter) {
reloadNotifications(clearItems: true)
}
.onChange(of: navigationState.notificationsRefreshTrigger) {
reloadNotifications()
}
.errorAlert(message: $pagination.errorMessage, isPresented: $pagination.showError)
}
@ -267,13 +271,17 @@ private struct MergedNotificationRow: View {
}
}
Text(notification.repository.fullName)
.font(.caption)
.foregroundStyle(.secondary)
Text(formatRelativeDate(notification.updatedAt))
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 4) {
Text(notification.repository.fullName)
.lineLimit(1)
.truncationMode(.middle)
Text("·")
.foregroundStyle(.tertiary)
Text(formatRelativeDate(notification.updatedAt))
.fixedSize()
}
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
@ -282,7 +290,6 @@ private struct MergedNotificationRow: View {
Circle()
.fill(.blue)
.frame(width: 10, height: 10)
.glassEffect(.regular.tint(.blue))
.padding(.top, 6)
}
}

View file

@ -6,6 +6,7 @@ import SwiftUI
struct DescriptionEditorSection: View {
@Binding var text: String
var title: String = "Description"
var onUploadImage: ((Data, String, String) async throws -> String)?
@State private var showingEditor = false
var body: some View {
@ -20,11 +21,16 @@ struct DescriptionEditorSection: View {
MarkdownPreview(text: text)
.font(.subheadline)
.lineLimit(3)
// The preview renders selectable text and tappable links. Inside a
// Button label those intercept the tap, so the editor sheet never
// opens. Disable hit testing so the tap falls through to the Button.
.allowsHitTesting(false)
}
}
.tint(.primary)
.accessibilityIdentifier("description-edit-button")
.sheet(isPresented: $showingEditor) {
DescriptionEditorSheet(text: $text, title: title)
DescriptionEditorSheet(text: $text, title: title, onUploadImage: onUploadImage)
}
}
}
@ -33,6 +39,7 @@ struct DescriptionEditorSection: View {
private struct DescriptionEditorSheet: View {
@Binding var text: String
let title: String
var onUploadImage: ((Data, String, String) async throws -> String)?
@State private var selectedTab: EditPreviewTab = .edit
@Environment(\.dismiss) private var dismiss
@ -42,6 +49,7 @@ private struct DescriptionEditorSheet: View {
text: $text,
selectedTab: $selectedTab,
showToolbar: true,
onUploadImage: onUploadImage,
)
.frame(maxHeight: .infinity)
.padding()

View file

@ -249,13 +249,17 @@ private struct NotificationRow: View {
.font(.body)
.lineLimit(2)
Text(notification.repository.fullName)
.font(.caption)
.foregroundStyle(.secondary)
Text(formatRelativeDate(notification.updatedAt))
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 4) {
Text(notification.repository.fullName)
.lineLimit(1)
.truncationMode(.middle)
Text("·")
.foregroundStyle(.tertiary)
Text(formatRelativeDate(notification.updatedAt))
.fixedSize()
}
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
@ -264,7 +268,6 @@ private struct NotificationRow: View {
Circle()
.fill(.blue)
.frame(width: 10, height: 10)
.glassEffect(.regular.tint(.blue))
.padding(.top, 6)
}
}

View file

@ -31,6 +31,7 @@ struct PullRequestDetailView: View {
@State private var showActionsExpanded = false
private let prService: PullRequestService?
private let issueService: IssueService?
private let repositoryService: RepositoryService?
init(repository: Repository, prNumber: Int, authService: AuthenticationService) {
@ -38,6 +39,7 @@ struct PullRequestDetailView: View {
self.prNumber = prNumber
self.authService = authService
prService = authService.client.map { PullRequestService(client: $0) }
issueService = authService.client.map { IssueService(client: $0) }
repositoryService = authService.client.map { RepositoryService(client: $0) }
}
@ -108,6 +110,13 @@ struct PullRequestDetailView: View {
}
}
if let assets = activePR.assets, !assets.isEmpty {
Section("Attachments") {
AttachmentGallery(attachments: assets)
.padding(.vertical, 4)
}
}
reviewsSection
changesSection
conflictSection(activePR)
@ -133,6 +142,7 @@ struct PullRequestDetailView: View {
repository: repository,
pullRequest: editingPR,
authService: authService,
onUploadImage: uploadPRImage,
) { _ in
Task { await loadData() }
}
@ -174,7 +184,7 @@ struct PullRequestDetailView: View {
}
}
.sheet(isPresented: $showCommentSheet) {
CommentSheet(users: assignees) { body in
CommentSheet(users: assignees, onUploadImage: uploadPRImage) { body in
guard let prService else { throw URLError(.userAuthenticationRequired) }
let comment = try await prService.createComment(
owner: owner,
@ -436,6 +446,15 @@ struct PullRequestDetailView: View {
reviewComments.values.flatMap(\.self)
}
private func uploadPRImage(data: Data, fileName: String, mimeType: String) async throws -> String {
guard let issueService else { throw URLError(.userAuthenticationRequired) }
let attachment = try await issueService.uploadIssueAttachment(
owner: owner, repo: repo, index: prNumber,
fileData: data, fileName: fileName, mimeType: mimeType,
)
return attachmentMarkdown(for: attachment)
}
private func loadData() async {
guard let prService, let repositoryService else { return }
isLoading = true
@ -586,6 +605,7 @@ struct PullRequestDetailView: View {
self.prNumber = prNumber
self.authService = authService
prService = nil
issueService = nil
repositoryService = nil
_pullRequest = State(initialValue: pullRequest)
_comments = State(initialValue: comments)

View file

@ -22,6 +22,7 @@ struct PullRequestEditView: View {
private let prService: PullRequestService?
private let repositoryService: RepositoryService?
private let onUploadImage: ((Data, String, String) async throws -> String)?
private let onSaved: (PullRequest) -> Void
private var isMerged: Bool {
@ -31,6 +32,7 @@ struct PullRequestEditView: View {
init(
repository: Repository, pullRequest: PullRequest,
authService: AuthenticationService,
onUploadImage: ((Data, String, String) async throws -> String)? = nil,
onSaved: @escaping (PullRequest) -> Void,
) {
self.repository = repository
@ -38,6 +40,7 @@ struct PullRequestEditView: View {
self.authService = authService
prService = authService.client.map { PullRequestService(client: $0) }
repositoryService = authService.client.map { RepositoryService(client: $0) }
self.onUploadImage = onUploadImage
self.onSaved = onSaved
_title = State(initialValue: pullRequest.title)
_bodyText = State(initialValue: pullRequest.body ?? "")
@ -64,7 +67,7 @@ struct PullRequestEditView: View {
.accessibilityIdentifier("pr-edit-title-field")
}
DescriptionEditorSection(text: $bodyText)
DescriptionEditorSection(text: $bodyText, onUploadImage: onUploadImage)
LabelPickerSection(
availableLabels: availableLabels,
@ -199,6 +202,7 @@ struct PullRequestEditView: View {
self.authService = authService
prService = nil
repositoryService = nil
onUploadImage = nil
onSaved = { _ in }
_title = State(initialValue: pullRequest.title)
_bodyText = State(initialValue: pullRequest.body ?? "")

View file

@ -38,6 +38,12 @@ struct PullRequestListView: View {
List {
if pagination.isLoading, pagination.items.isEmpty {
LoadingListSection()
} else if pagination.notFound {
ContentUnavailableView {
Label("Pull Requests Unavailable", systemImage: "exclamationmark.circle")
} description: {
Text("Pull requests are not available for this repository.")
}
} else if pagination.items.isEmpty {
ContentUnavailableView {
Label(
@ -49,7 +55,7 @@ struct PullRequestListView: View {
} description: {
Text(
stateFilter == .open
? "All clear no open pull requests."
? "All clear, no open pull requests."
: "No closed pull requests found.",
)
}
@ -116,13 +122,18 @@ struct PullRequestListView: View {
private func reloadPullRequests(clearItems: Bool = false) -> Task<Void, Never> {
guard let prService else { return Task {} }
return pagination.reload(clearItems: clearItems) { [self] page, limit in
try await prService.fetchPullRequests(
owner: owner,
repo: repo,
state: stateFilter.rawValue,
page: page,
limit: limit,
)
do {
return try await prService.fetchPullRequests(
owner: owner,
repo: repo,
state: stateFilter.rawValue,
page: page,
limit: limit,
)
} catch let error as ServiceError where error.httpStatusCode == 404 {
pagination.notFound = true
return []
}
}
}
@ -140,11 +151,13 @@ struct PullRequestListView: View {
}
#if DEBUG
init(preview _: Void, repository: Repository, authService: AuthenticationService, pullRequests: [PullRequest]) {
init(preview _: Void, repository: Repository, authService: AuthenticationService, pullRequests: [PullRequest],
notFound: Bool = false)
{
self.repository = repository
self.authService = authService
prService = nil
_pagination = State(initialValue: PaginationState(items: pullRequests))
_pagination = State(initialValue: PaginationState(items: pullRequests, notFound: notFound))
}
#endif
}
@ -160,6 +173,18 @@ struct PullRequestListView: View {
)
}
}
#Preview("Pull Requests Unavailable") {
NavigationStack {
PullRequestListView(
preview: (),
repository: .preview,
authService: .previewDefault,
pullRequests: [],
notFound: true,
)
}
}
#endif
struct PullRequestRow: View {

View file

@ -30,7 +30,7 @@ struct RepositoryDetailView: View {
enum DetailTab: String {
case code = "Code"
case issues = "Issues"
case pulls = "Pull Requests"
case pulls = "PRs"
case actions = "Actions"
var icon: String {
@ -185,7 +185,7 @@ struct RepositoryDetailView: View {
repo: repository.repoName,
)
} catch {
// Non-critical branch selector stays disabled
// Non-critical, branch selector stays disabled
}
}
.toolbar {
@ -504,7 +504,7 @@ struct RepositoryCodeView: View {
)
readmeContent = fileContent.decodedContent
} catch {
// README exists in listing but couldn't be fetched ignore
// README exists in listing but couldn't be fetched, ignore
}
}

View file

@ -59,6 +59,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
let state = PaginationState<Issue>()
state.cacheName = issueType
state.dedupeKey = { $0.id }
state.loadFromCache()
_pagination = State(initialValue: state)
}
@ -68,7 +69,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
return "No \(itemNoun) matching your search."
}
if stateFilter == .open {
return "All clear no open \(itemNoun) to review."
return "All clear, no open \(itemNoun) to review."
}
let prefix = stateFilter == .all ? "" : stateFilter.rawValue + " "
return "No \(prefix)\(itemNoun) found."
@ -84,7 +85,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
case .closed: "Closed"
case .all: "All"
}
// Scope is ignored during search only show state
// Scope is ignored during search, only show state
if !searchText.isEmpty || (stateFilter == .open && involvementScope == .involved) {
return stateLabel
}
@ -144,13 +145,15 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
}
.listStyle(.insetGrouped)
.safeAreaInset(edge: .top) {
Text(filterSummaryText)
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.horizontal)
.padding(.vertical, 6)
.accessibilityIdentifier("filter-summary")
if hasNonDefaultFilters {
Text(filterSummaryText)
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.horizontal)
.padding(.vertical, 6)
.accessibilityIdentifier("filter-summary")
}
}
}

View file

@ -72,7 +72,7 @@ struct WorkflowJobView: View {
Label("No Steps Recorded", systemImage: "tray")
.foregroundStyle(.secondary)
} description: {
Text("This job has no recorded steps it likely never started.")
Text("This job has no recorded steps, it likely never started.")
}
} header: {
experimentalHeader

View file

@ -196,7 +196,7 @@ struct WorkflowRunDetailView: View {
isLoading = false
// Best-effort experimental fetch don't surface errors if it fails
// Best-effort experimental fetch, don't surface errors if it fails
// (e.g. older Forgejo versions without this route, or auth quirks).
if let runIndex = run?.indexInRepo {
await loadRunView(runIndex: runIndex)
@ -214,7 +214,7 @@ struct WorkflowRunDetailView: View {
logCursors: [],
)
} catch {
// Silent experimental endpoint failure shouldn't block run detail.
// Silent, experimental endpoint failure shouldn't block run detail.
}
}
@ -222,7 +222,7 @@ struct WorkflowRunDetailView: View {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute, .second]
formatter.unitsStyle = .abbreviated
return formatter.string(from: seconds) ?? ""
return formatter.string(from: seconds) ?? "-"
}
}

View file

@ -127,7 +127,7 @@ struct KeychainManagerTests {
_ = try await KeychainManager.shared.getPassword(for: raw, username: "user")
Issue.record("Should not find password with non-normalized key")
} catch is KeychainError {
// Expected keys differ
// Expected, keys differ
}
// Clean up
@ -156,7 +156,7 @@ struct KeychainManagerTests {
_ = try await KeychainManager.shared.getToken(for: server, username: username)
Issue.record("Expected KeychainError.notFound for token after deleteCredentials")
} catch is KeychainError {
// Expected this is the assertion that fails under the old leak
// Expected, this is the assertion that fails under the old leak
} catch {
Issue.record("Unexpected error: \(error)")
}

View file

@ -0,0 +1,41 @@
@testable import Forji
import SwiftUI
import Testing
struct LabelColorHexTests {
// MARK: - Six-digit colors
@Test("six digit hex parses") func sixDigitHexParses() {
#expect(Color(hex: "#ff0000") != nil)
#expect(Color(hex: "00ff00") != nil)
}
@Test("six digit value matches") func sixDigitValueMatches() throws {
let rgb = try #require(Color.rgbValue(hex: "#3366cc"))
#expect(rgb == 0x3366CC)
}
// MARK: - Three-digit shorthand
@Test("three digit shorthand parses") func threeDigitShorthandParses() {
#expect(Color(hex: "#f00") != nil)
#expect(Color(hex: "abc") != nil)
}
@Test("three digit shorthand expands like six digit") func threeDigitShorthandExpandsLikeSixDigit() throws {
#expect(Color(hex: "#f00") == Color(hex: "#ff0000"))
#expect(Color(hex: "abc") == Color(hex: "aabbcc"))
let rgb = try #require(Color.rgbValue(hex: "#f00"))
#expect(rgb == 0xFF0000)
}
// MARK: - Invalid input
@Test("invalid hex returns nil") func invalidHexReturnsNil() {
#expect(Color(hex: "") == nil)
#expect(Color(hex: "#ff00") == nil)
#expect(Color(hex: "12345") == nil)
#expect(Color(hex: "xyzxyz") == nil)
#expect(Color(hex: "#xyz") == nil)
}
}

View file

@ -1,16 +1,50 @@
import Foundation
import ForgejoKit
import Testing
@testable import Forji
struct MarkdownComponentsTests {
private func attachment(name: String, type: String?) -> ForgejoKit.Attachment {
ForgejoKit.Attachment(
id: 1, uuid: "u", name: name, size: 0,
browserDownloadUrl: "https://example.com/a/\(name)",
type: type, created: Date(),
)
}
// MARK: - attachmentMarkdown
@Test func imageAttachmentUsesEmbedSyntax() {
let md = attachmentMarkdown(for: attachment(name: "shot.png", type: "image/png"))
#expect(md == "![shot.png](https://example.com/a/shot.png)")
}
@Test func imageDetectedByExtensionUsesEmbedSyntax() {
// Forgejo returns the generic type "attachment", so detection falls to the extension.
let md = attachmentMarkdown(for: attachment(name: "photo.jpg", type: "attachment"))
#expect(md == "![photo.jpg](https://example.com/a/photo.jpg)")
}
@Test func nonImageFileUsesLinkSyntax() {
let md = attachmentMarkdown(for: attachment(name: "server.log", type: "text/plain"))
#expect(md == "[server.log](https://example.com/a/server.log)")
}
@Test func nonImageBinaryUsesLinkSyntax() {
let md = attachmentMarkdown(for: attachment(name: "report.pdf", type: "attachment"))
#expect(md == "[report.pdf](https://example.com/a/report.pdf)")
}
// MARK: - repoRelativePath
@Test func repoRelativePathExtractsFilePath() {
let url = URL(string: "https://forgejo.example.com/owner/repo/src/branch/main/path/to/file.swift")!
#expect(repoRelativePath(from: url) == "path/to/file.swift")
}
@Test func repoRelativePathReturnsNilForBranchOnly() {
// URL ends right after the ref no file path follows
// URL ends right after the ref, no file path follows
let url = URL(string: "https://forgejo.example.com/owner/repo/src/branch/main")!
#expect(repoRelativePath(from: url) == nil)
}

View file

@ -71,12 +71,12 @@ struct NotificationPollingIntegrationTests {
let ids = Set(notifications.map(\.id))
#expect(!ids.isEmpty)
// Seed should record all current IDs
// Seed, should record all current IDs
store.seed(ids: ids, for: normalizedURL, username: Self.username)
#expect(store.isSeeded(for: normalizedURL, username: Self.username))
#expect(store.seenIDs(for: normalizedURL, username: Self.username) == ids)
// Simulate a poll returning the same IDs no new notifications
// Simulate a poll returning the same IDs, no new notifications
let newIDs = ids.subtracting(store.seenIDs(for: normalizedURL, username: Self.username))
#expect(newIDs.isEmpty, "After seeding with current IDs, diff should be empty")
}
@ -93,11 +93,11 @@ struct NotificationPollingIntegrationTests {
let allIDs = Set(notifications.map(\.id))
#expect(allIDs.count >= 2, "Need at least 2 notifications for this test")
// Seed with only a subset simulates having seen all but one
// Seed with only a subset, simulates having seen all but one
let partial = Set(allIDs.dropFirst())
store.seed(ids: partial, for: normalizedURL, username: Self.username)
// Now diff the first ID should show as "new"
// Now diff, the first ID should show as "new"
let seenIDs = store.seenIDs(for: normalizedURL, username: Self.username)
let newIDs = allIDs.subtracting(seenIDs)
#expect(newIDs.count == 1, "Expected exactly 1 new notification after partial seed")
@ -123,7 +123,7 @@ struct NotificationPollingIntegrationTests {
store.seed(ids: seeded, for: normalizedURL, username: Self.username)
#expect(store.seenIDs(for: normalizedURL, username: Self.username).contains(999_999))
// Prune only keep IDs that the server still reports as unread
// Prune, only keep IDs that the server still reports as unread
store.prune(keeping: serverIDs, for: normalizedURL, username: Self.username)
#expect(!store.seenIDs(for: normalizedURL, username: Self.username).contains(999_999))
#expect(store.seenIDs(for: normalizedURL, username: Self.username) == serverIDs)
@ -150,7 +150,7 @@ struct NotificationPollingIntegrationTests {
let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")
#expect(!store.isSeeded(for: normalizedURL, username: Self.username))
// Seed the store first poll seeds without posting notifications
// Seed the store, first poll seeds without posting notifications
let firstPollIDs = Set(notifications.map(\.id))
store.seed(ids: firstPollIDs, for: normalizedURL, username: Self.username)
@ -191,7 +191,7 @@ struct NotificationPollingIntegrationTests {
// Save with current (post-migration) accessibility
try await KeychainManager.shared.saveToken(token, for: server, username: user)
// Run migration should read + delete + re-save without losing data
// Run migration, should read + delete + re-save without losing data
await KeychainManager.shared.migrateAccessibility(for: server, username: user)
// Verify token is still accessible
@ -283,7 +283,7 @@ struct NotificationPollingIntegrationTests {
let service = NotificationService(client: client)
let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")
// Initial poll seed seen store
// Initial poll, seed seen store
let initial = try await service.fetchNotifications(
statusTypes: ["unread"], page: 1, limit: 50
)
@ -295,14 +295,14 @@ struct NotificationPollingIntegrationTests {
let markedID = initial[0].id
try await service.markAsRead(id: markedID)
// Next poll fewer unreads
// Next poll, fewer unreads
let afterMark = try await service.fetchNotifications(
statusTypes: ["unread"], page: 1, limit: 50
)
let afterIDs = Set(afterMark.map(\.id))
#expect(!afterIDs.contains(markedID), "Marked notification should no longer be unread")
// Prune the marked ID should be removed from seen store
// Prune, the marked ID should be removed from seen store
store.prune(keeping: afterIDs, for: normalizedURL, username: Self.username)
#expect(
!store.seenIDs(for: normalizedURL, username: Self.username).contains(markedID),
@ -319,7 +319,7 @@ struct NotificationPollingIntegrationTests {
let server = "https://test.example.com"
let user = "testuser"
// Seed with partial IDs ID 1 is "seen", ID 2 is "new"
// Seed with partial IDs, ID 1 is "seen", ID 2 is "new"
store.seed(ids: [1], for: server, username: user)
// Simulate a poll returning IDs [1, 2]
@ -460,12 +460,12 @@ struct NotificationPollingIntegrationTests {
let allIDs = Set(notifications.map(\.id))
#expect(allIDs.count >= 2, "Need at least 2 notifications")
// Seed with a subset simulate having seen all but one
// Seed with a subset, simulate having seen all but one
let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")
let partial = Set(allIDs.dropFirst())
store.seed(ids: partial, for: normalizedURL, username: Self.username)
// Diff should detect the missing ID as new
// Diff, should detect the missing ID as new
let seenIDs = store.seenIDs(for: normalizedURL, username: Self.username)
let newIDs = allIDs.subtracting(seenIDs)
#expect(newIDs.count == 1, "Expected exactly 1 new notification")

View file

@ -1,3 +1,4 @@
// swiftlint:disable file_length
import Foundation
import Testing
@testable import Forji
@ -146,13 +147,13 @@ struct PaginationStateConcurrentTests {
}
await yieldUntil { fetcherA.isPending }
// Start reload B cancels A's internal task
// Start reload B, cancels A's internal task
let taskB = pagination.reload { page, limit in
try await fetcherB.fetch(page: page, limit: limit)
}
await yieldUntil { fetcherB.isPending }
// Complete both only B's results should be applied
// Complete both, only B's results should be applied
fetcherA.complete(returning: ["stale"])
fetcherB.complete(returning: ["fresh"])
await taskB.value
@ -176,7 +177,7 @@ struct PaginationStateConcurrentTests {
}
await yieldUntil { fetcherB.isPending }
// Complete A first (stale, its task was cancelled) must be discarded
// Complete A first (stale, its task was cancelled), must be discarded
fetcherA.complete(returning: ["stale"])
await yieldUntil { fetcherA.callCount == 1 }
@ -212,7 +213,7 @@ struct PaginationStateConcurrentTests {
}
await yieldUntil { fetcherC.isPending }
// Complete in order A, B, C only C should be applied
// Complete in order A, B, C, only C should be applied
fetcherA.complete(returning: ["a"])
fetcherB.complete(returning: ["b"])
fetcherC.complete(returning: ["c"])
@ -237,7 +238,7 @@ struct PaginationStateConcurrentTests {
}
await yieldUntil { fetcherB.isPending }
// A fails with error but its task was cancelled, so no alert
// A fails with error, but its task was cancelled, so no alert
fetcherA.complete(throwing: URLError(.badServerResponse))
fetcherB.complete(returning: ["ok"])
await taskB.value
@ -263,7 +264,7 @@ struct PaginationStateConcurrentTests {
await yieldUntil { fetcherB.isPending }
#expect(pagination.isLoading)
// Complete stale A isLoading must REMAIN true (B is still in flight)
// Complete stale A, isLoading must REMAIN true (B is still in flight)
fetcherA.complete(returning: ["stale"])
await yieldUntil { fetcherA.callCount == 1 }
#expect(pagination.isLoading, "isLoading must stay true while latest reload (B) is pending")
@ -286,7 +287,7 @@ struct PaginationStateConcurrentTests {
}
await yieldUntil { initialFetcher.isPending }
// Simulate onChange: user changed filter just call reload again
// Simulate onChange: user changed filter, just call reload again
let filterTask = pagination.reload(clearItems: true) { page, limit in
try await filterFetcher.fetch(page: page, limit: limit)
}
@ -359,6 +360,41 @@ struct PaginationStateHasLoadedTests {
}
}
// MARK: - notFound
struct PaginationStateNotFoundTests {
@Test @MainActor func notFoundFalseInitially() {
let pagination = PaginationState<String>(pageSize: 5)
#expect(!pagination.notFound)
}
@Test @MainActor func notFoundSetByFetchClosureShowsNoAlert() async {
let pagination = PaginationState<String>(pageSize: 20)
await pagination.reload { _, _ in
pagination.notFound = true
return []
}.value
#expect(pagination.notFound)
#expect(pagination.items.isEmpty)
#expect(!pagination.showError)
#expect(pagination.hasLoaded)
}
@Test @MainActor func reloadClearsNotFound() async {
let pagination = PaginationState<String>(pageSize: 20)
await pagination.reload { _, _ in
pagination.notFound = true
return []
}.value
#expect(pagination.notFound)
await pagination.reload { _, _ in ["ok"] }.value
#expect(!pagination.notFound)
#expect(pagination.items == ["ok"])
}
}
// MARK: - loadMore
struct PaginationStateLoadMoreTests {
@ -368,7 +404,7 @@ struct PaginationStateLoadMoreTests {
await pagination.reload { _, _ in ["a", "b"] }.value
#expect(pagination.hasMore)
await pagination.loadMore { page, limit in
await pagination.loadMore { page, _ in
#expect(page == 2)
return ["c", "d"]
}
@ -427,11 +463,11 @@ struct PaginationStateLoadMoreTests {
}
await yieldUntil { freshFetcher.isPending }
// loadMore completes should be discarded (task was cancelled)
// loadMore completes, should be discarded (task was cancelled)
moreFetcher.complete(returning: ["stale-more"])
await moreTask.value
// reload completes should be applied
// reload completes, should be applied
freshFetcher.complete(returning: ["fresh"])
await reloadTask.value
@ -439,3 +475,50 @@ struct PaginationStateLoadMoreTests {
#expect(!pagination.isLoading)
}
}
// MARK: - Keyed de-duplication
struct PaginationStateDedupeTests {
@Test @MainActor func loadMoreFiltersKeysSeenOnEarlierPages() async {
let pagination = PaginationState<String>(pageSize: 2)
pagination.dedupeKey = { $0 }
await pagination.reload { _, _ in ["a", "b"] }.value
// "b" resurfaces on page 2, only "c" is new
await pagination.loadMore { _, _ in ["b", "c"] }
#expect(pagination.items == ["a", "b", "c"])
#expect(pagination.hasMore) // the page contributed a new item
}
@Test @MainActor func hasMoreFollowsNewItemsNotPageSize() async {
let pagination = PaginationState<String>(pageSize: 5)
pagination.dedupeKey = { $0 }
await pagination.reload { _, _ in ["a", "b"] }.value
#expect(pagination.hasMore) // 2 < pageSize 5, but the page contributed new items
}
@Test @MainActor func hasMoreFalseWhenFullPageIsAllDuplicates() async {
let pagination = PaginationState<String>(pageSize: 2)
pagination.dedupeKey = { $0 }
await pagination.reload { _, _ in ["a", "b"] }.value
#expect(pagination.hasMore)
// 2 >= pageSize 2, but every key was already seen -> pagination ends
await pagination.loadMore { _, _ in ["a", "b"] }
#expect(pagination.items == ["a", "b"])
#expect(!pagination.hasMore)
}
@Test @MainActor func reloadResetsSeenKeys() async {
let pagination = PaginationState<String>(pageSize: 2)
pagination.dedupeKey = { $0 }
await pagination.reload { _, _ in ["a", "b"] }.value
await pagination.loadMore { _, _ in ["b", "c"] }
#expect(pagination.items == ["a", "b", "c"])
// A fresh reload must accept keys seen in the previous cycle
await pagination.reload { _, _ in ["a", "b"] }.value
#expect(pagination.items == ["a", "b"])
}
}

View file

@ -0,0 +1,179 @@
import XCTest
final class AttachmentUITests: ForgejoUITestBase {
// Tests that multiple images can be uploaded from the markdown toolbar comment
// sheet, and that both uploaded images appear in the issue's Attachments section
// on reload. The -dev_testImageUpload launch argument bypasses the PhotosPicker
// and uploads a hardcoded 1×1 PNG directly, keeping the test deterministic without
// requiring photos in the simulator's library.
@MainActor
func testUploadMultipleImagesInCommentAndVerifyDisplayed() throws {
app.launchArguments += ["-dev_testImageUpload"]
loginAndWaitForHome()
// Navigate to an existing issue
navigateToIssueTab()
let issueCell = app.staticTexts["Test issue 1 from integration tests"].firstMatch
XCTAssertTrue(issueCell.waitForExistence(timeout: 10))
issueCell.tap()
XCTAssertTrue(app.staticTexts["issue-detail-title"].waitForExistence(timeout: 10))
// Open the comment sheet
expandActionMenu()
let commentButton = app.buttons["issue-comment-button"]
XCTAssertTrue(commentButton.waitForExistence(timeout: 5))
commentButton.tap()
// Tap the image upload button twice, in test mode each tap uploads a
// hardcoded PNG instead of presenting the Photos picker. Two taps produce
// two distinct attachments and two markdown references.
let imageButton = app.buttons["markdown-toolbar-image"]
XCTAssertTrue(imageButton.waitForExistence(timeout: 5))
let textEditor = app.textViews["markdown-text-editor"].firstMatch
XCTAssertTrue(textEditor.waitForExistence(timeout: 5))
// First upload, wait until one markdown reference is inserted before tapping
// again, so the second upload appends rather than racing the first.
imageButton.tap()
expectation(for: NSPredicate(format: "value MATCHES '.*!\\\\[.*'"), evaluatedWith: textEditor)
waitForExpectations(timeout: 15, handler: nil)
// Second upload, wait until two markdown references are present.
imageButton.tap()
let twoRefs = NSPredicate(format: "value MATCHES '(.*!\\\\[.*){2,}'")
expectation(for: twoRefs, evaluatedWith: textEditor)
waitForExpectations(timeout: 15, handler: nil)
let editorValue = textEditor.value as? String ?? ""
XCTAssertEqual(
editorValue.components(separatedBy: "![").count - 1, 2,
"Expected two markdown image references in the editor after two uploads"
)
// Submit the comment
app.buttons["Submit"].tap()
// Wait for the comment sheet (NavigationStack title "New Comment") to dismiss
let commentSheetNavBar = app.navigationBars["New Comment"]
expectation(for: NSPredicate(format: "exists == false"), evaluatedWith: commentSheetNavBar)
waitForExpectations(timeout: 10, handler: nil)
// Navigate back to the issue list
app.navigationBars.buttons.firstMatch.tap()
XCTAssertTrue(
app.staticTexts["Test issue 1 from integration tests"].waitForExistence(timeout: 10),
"Should return to the issue list after navigating back"
)
// Re-enter the issue: loadData() fetches the issue fresh,
// now including the uploaded attachment in issue.assets.
app.staticTexts["Test issue 1 from integration tests"].firstMatch.tap()
XCTAssertTrue(app.staticTexts["issue-detail-title"].waitForExistence(timeout: 10))
// Scroll past the issue header, milestone, assignees, and description
// to reach the Attachments section that appears below them.
app.swipeUp()
app.swipeUp()
// The Attachments section must now be visible with the gallery rendered.
XCTAssertTrue(
app.staticTexts["Attachments"].waitForExistence(timeout: 10),
"Attachments section header should appear after uploading images to the issue"
)
let gallery = app.scrollViews["attachment-gallery"]
XCTAssertTrue(
gallery.waitForExistence(timeout: 5),
"AttachmentGallery image scroll view should be rendered inside the Attachments section"
)
// Both uploaded images must render as thumbnails. Each thumbnail is a Link
// wrapping an AsyncImage, exposed as an image element inside the gallery.
let twoImages = NSPredicate(format: "count >= 2")
expectation(for: twoImages, evaluatedWith: gallery.images)
waitForExpectations(timeout: 10, handler: nil)
XCTAssertGreaterThanOrEqual(
gallery.images.count, 2,
"Both uploaded images should render as thumbnails in the gallery"
)
}
// Tests that a non-image attachment (a text/log file) is handled correctly:
// the inserted markdown is a plain link (not an image embed), and after reload
// the file renders as a file row (paperclip + name) rather than an image
// thumbnail. The -dev_testImageUpload launch argument also exposes a companion
// "Attach file" button that uploads a hardcoded server.log.
@MainActor
func testUploadLogFileInCommentRendersAsFileRow() throws {
app.launchArguments += ["-dev_testImageUpload"]
loginAndWaitForHome()
// Navigate to an existing issue
navigateToIssueTab()
let issueCell = app.staticTexts["Test issue 2 from integration tests"].firstMatch
XCTAssertTrue(issueCell.waitForExistence(timeout: 10))
issueCell.tap()
XCTAssertTrue(app.staticTexts["issue-detail-title"].waitForExistence(timeout: 10))
// Open the comment sheet
expandActionMenu()
let commentButton = app.buttons["issue-comment-button"]
XCTAssertTrue(commentButton.waitForExistence(timeout: 5))
commentButton.tap()
// Tap the file upload button, in test mode this uploads a hardcoded server.log.
let fileButton = app.buttons["markdown-toolbar-file"]
XCTAssertTrue(fileButton.waitForExistence(timeout: 5))
let textEditor = app.textViews["markdown-text-editor"].firstMatch
XCTAssertTrue(textEditor.waitForExistence(timeout: 5))
fileButton.tap()
// Wait for the upload to finish: a markdown reference is inserted.
expectation(for: NSPredicate(format: "value CONTAINS 'server.log'"), evaluatedWith: textEditor)
waitForExpectations(timeout: 15, handler: nil)
// A non-image attachment must use link syntax [name](url), NOT image-embed
// syntax ![name](url), otherwise it would render as a broken inline image.
let editorValue = textEditor.value as? String ?? ""
XCTAssertTrue(
editorValue.contains("[server.log]"),
"Expected a markdown link to the uploaded log file"
)
XCTAssertFalse(
editorValue.contains("!["),
"A non-image file must not use image-embed markdown syntax"
)
// Submit the comment
app.buttons["Submit"].tap()
let commentSheetNavBar = app.navigationBars["New Comment"]
expectation(for: NSPredicate(format: "exists == false"), evaluatedWith: commentSheetNavBar)
waitForExpectations(timeout: 10, handler: nil)
// Navigate back to the issue list, then re-enter to reload issue.assets.
app.navigationBars.buttons.firstMatch.tap()
let listCell = app.staticTexts["Test issue 2 from integration tests"]
XCTAssertTrue(listCell.waitForExistence(timeout: 10))
listCell.firstMatch.tap()
XCTAssertTrue(app.staticTexts["issue-detail-title"].waitForExistence(timeout: 10))
// Scroll down to the Attachments section.
app.swipeUp()
app.swipeUp()
XCTAssertTrue(
app.staticTexts["Attachments"].waitForExistence(timeout: 10),
"Attachments section header should appear after uploading a file"
)
// The log must render as a file row (not an image thumbnail), showing its name.
// A SwiftUI Link may surface as a link or a button, so match any element type.
let fileRow = app.descendants(matching: .any).matching(identifier: "attachment-file-row").firstMatch
XCTAssertTrue(
fileRow.waitForExistence(timeout: 5),
"Non-image attachment should render as a file row"
)
XCTAssertTrue(
app.staticTexts["server.log"].waitForExistence(timeout: 5),
"File row should display the attachment file name"
)
}
}

View file

@ -14,7 +14,7 @@ class ForgejoReadOnlyUITestBase: XCTestCase, UITestNavigating {
guard let url = ForgejoUITestBase.resolveTestServerURL(),
!url.isEmpty
else {
// Can't XCTSkip from class setUp individual tests will skip
// Can't XCTSkip from class setUp, individual tests will skip
return
}
sharedServerURL = url
@ -60,7 +60,7 @@ class ForgejoReadOnlyUITestBase: XCTestCase, UITestNavigating {
// Ensure we're at the home screen (Repositories tab as anchor)
let reposTab = app.tabBars.buttons["Repositories"]
if !reposTab.waitForExistence(timeout: 5) {
// Recovery: app may have crashed or navigated away relaunch
// Recovery: app may have crashed or navigated away, relaunch
let freshApp = XCUIApplication()
freshApp.launchArguments += [
"-dev_serverURL", Self.sharedServerURL,

View file

@ -9,7 +9,7 @@ final class ForjiUITestsLaunchTests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
// Skip during integration test runs these tests need manual credentials
// Skip during integration test runs, these tests need manual credentials
if FileManager.default.fileExists(atPath: "/tmp/forgejo_test_url.txt") {
throw XCTSkip("Skipping launch tests during integration test run")
}

View file

@ -26,7 +26,7 @@ final class IssueMutatingUITests: ForgejoUITestBase {
openButton.tap()
XCTAssertTrue(app.staticTexts["Test issue 1 from integration tests"].waitForExistence(timeout: 10))
// Issue detail title, comments, label, milestone, assignee
// Issue detail, title, comments, label, milestone, assignee
let issueCell = app.staticTexts["Test issue 1 from integration tests"].firstMatch
issueCell.tap()
@ -67,11 +67,36 @@ final class IssueMutatingUITests: ForgejoUITestBase {
editTitleField.tap(withNumberOfTaps: 3, numberOfTouches: 1)
editTitleField.typeText("Edited issue title")
// Edit the description. Tapping the description row must open the editor
// sheet, the regression from issue #80 was that the selectable markdown
// preview swallowed the tap so the sheet never appeared.
let descriptionButton = app.buttons["description-edit-button"]
XCTAssertTrue(descriptionButton.waitForExistence(timeout: 5))
descriptionButton.tap()
let descriptionEditor = app.textViews["markdown-text-editor"]
XCTAssertTrue(
descriptionEditor.waitForExistence(timeout: 5),
"Tapping the description should open the editor sheet",
)
descriptionEditor.tap()
descriptionEditor.typeText("UITESTDESC ")
app.buttons["Done"].tap()
let saveButton = app.buttons["issue-edit-save"]
XCTAssertTrue(saveButton.waitForExistence(timeout: 5))
saveButton.tap()
XCTAssertTrue(app.staticTexts["Edited issue title"].waitForExistence(timeout: 10))
// The edited description must be persisted and rendered in the detail view.
let editedDescription = app.staticTexts.element(
matching: NSPredicate(format: "label CONTAINS %@", "UITESTDESC"),
)
XCTAssertTrue(
editedDescription.waitForExistence(timeout: 10),
"Edited description should appear in the issue detail",
)
// Close and reopen
expandActionMenu()
let toggleButton = app.buttons["issue-toggle-state"]

View file

@ -37,17 +37,17 @@ final class IssueUITests: ForgejoReadOnlyUITestBase {
// Wait for list to load
XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 10), "Issues should load")
// Default filter summary should show "Open" (default scope is All involvement)
// In the default state (Open + All involvement) the summary is hidden.
let filterSummary = app.staticTexts["filter-summary"]
XCTAssertTrue(filterSummary.waitForExistence(timeout: 5))
XCTAssertEqual(filterSummary.label, "Open")
XCTAssertFalse(filterSummary.exists, "Filter summary should be hidden in the default state")
let filterMenuButton = app.buttons["filter-menu-button"]
// Tap "Assigned to you" via filter menu
// Selecting a non-default scope reveals the summary.
filterMenuButton.tap()
app.buttons["Assigned to you"].tap()
sleep(2)
XCTAssertTrue(filterSummary.waitForExistence(timeout: 5))
XCTAssertTrue(filterSummary.label.contains("Assigned to you"))
// Tap "Mentioned" via filter menu

View file

@ -36,7 +36,7 @@ final class MergedInstanceUITests: ForgejoUITestBase {
XCTAssertTrue(addButton.waitForExistence(timeout: 15), "Instance list did not appear")
addButton.tap()
// Form is pre-filled from dev launch args scroll and tap login
// Form is pre-filled from dev launch args, scroll and tap login
app.swipeUp()
let loginButton = app.buttons["login-button"]

View file

@ -2,7 +2,7 @@ import XCTest
final class OverviewCreateMutatingUITests: ForgejoUITestBase {
// MARK: - Create Issue from Issues Overview (mutates creates an issue)
// MARK: - Create Issue from Issues Overview (mutates, creates an issue)
@MainActor
func testCreateIssueFromOverview() throws {

View file

@ -13,7 +13,7 @@ final class OverviewCreateUITests: ForgejoReadOnlyUITestBase {
func testCreatePRFromOverview() throws {
app.tabBars.buttons["Pull Requests"].tap()
// Default scope is "All involvement" testadmin is assigned to PR #4
// Default scope is "All involvement", testadmin is assigned to PR #4
XCTAssertTrue(app.staticTexts["Add feature file"].waitForExistence(timeout: 10))
// Tap floating create button

View file

@ -53,7 +53,7 @@ final class PaginationUITests: ForgejoReadOnlyUITestBase {
XCTAssertTrue(closedButton.waitForExistence(timeout: 5))
closedButton.tap()
// Only 1 closed issue (#3) should not show load-more
// Only 1 closed issue (#3), should not show load-more
XCTAssertTrue(app.staticTexts["Test issue 3 from integration tests"].waitForExistence(timeout: 10))
let loadMore = app.activityIndicators["load-more-indicator"]

View file

@ -14,7 +14,7 @@ final class PermissionUITests: ForgejoUITestBase {
]
}
// MARK: - Issue Detail read-only user
// MARK: - Issue Detail, read-only user
@MainActor
func testIssueDetailHidesActionsForReadOnlyUser() throws {
@ -43,7 +43,7 @@ final class PermissionUITests: ForgejoUITestBase {
XCTAssertFalse(toggleStateButton.exists, "Close/Reopen button should be hidden for read-only user")
}
// MARK: - PR Detail read-only user
// MARK: - PR Detail, read-only user
@MainActor
func testPRDetailHidesActionsForReadOnlyUser() throws {

View file

@ -29,7 +29,7 @@ final class PullRequestUITests: ForgejoReadOnlyUITestBase {
openButton.tap()
XCTAssertTrue(app.staticTexts["Add feature file"].waitForExistence(timeout: 10))
// PR detail title, branches
// PR detail, title, branches
let prCell = app.staticTexts["Add feature file"].firstMatch
prCell.tap()
@ -55,7 +55,7 @@ final class PullRequestUITests: ForgejoReadOnlyUITestBase {
XCTAssertTrue(app.staticTexts["testbot"].waitForExistence(timeout: 10))
}
// MARK: - Diff View context line has no comment button
// MARK: - Diff View, context line has no comment button
@MainActor
func testDiffViewContextLineHasNoCommentButton() throws {
@ -111,17 +111,17 @@ final class PullRequestUITests: ForgejoReadOnlyUITestBase {
// Reset filters to defaults (previous test may have changed them)
resetOverviewFilters()
// Default filter summary should show "Open" (default scope is All involvement)
// In the default state (Open + All involvement) the summary is hidden.
let filterSummary = app.staticTexts["filter-summary"]
XCTAssertTrue(filterSummary.waitForExistence(timeout: 5))
XCTAssertEqual(filterSummary.label, "Open")
XCTAssertFalse(filterSummary.exists, "Filter summary should be hidden in the default state")
let filterMenuButton = app.buttons["filter-menu-button"]
// Tap "Assigned to you" via filter menu
// Selecting a non-default scope reveals the summary.
filterMenuButton.tap()
app.buttons["Assigned to you"].tap()
sleep(2)
XCTAssertTrue(filterSummary.waitForExistence(timeout: 5))
XCTAssertTrue(filterSummary.label.contains("Assigned to you"))
// Tap "Mentioned" via filter menu

View file

@ -33,7 +33,7 @@ final class RepositoryMutatingUITests: ForgejoUITestBase {
toggleButton.tap()
toggleButton.tap()
// File viewer tap hello.py
// File viewer, tap hello.py
let fileCell = app.staticTexts["hello.py"].firstMatch
XCTAssertTrue(fileCell.waitForExistence(timeout: 10))
fileCell.tap()

View file

@ -38,7 +38,7 @@ final class RepositoryUITests: ForgejoReadOnlyUITestBase {
searchField.buttons["Clear text"].tap()
XCTAssertTrue(app.staticTexts["test-repo"].waitForExistence(timeout: 10))
// Star toggle (last tap triggers NavigationLink, only tests tappability)
// Star toggle (last, tap triggers NavigationLink, only tests tappability)
let starButton = repoList.buttons["star-button"].firstMatch
XCTAssertTrue(starButton.waitForExistence(timeout: 5), "No star button found in repo list")
starButton.tap()
@ -130,7 +130,7 @@ final class RepositoryUITests: ForgejoReadOnlyUITestBase {
// Wait for repo detail to load
XCTAssertTrue(app.staticTexts["html-readme-repo"].waitForExistence(timeout: 10))
// Raw HTML tags should NOT appear as plain text they should be rendered
// Raw HTML tags should NOT appear as plain text, they should be rendered
XCTAssertFalse(
app.staticTexts["<details>"].waitForExistence(timeout: 5),
"Raw <details> tag should not be visible as plain text")

View file

@ -25,7 +25,7 @@ extension UITestNavigating {
navigateToRepoDetail()
let picker = app.segmentedControls["repo-detail-tab-picker"]
XCTAssertTrue(picker.waitForExistence(timeout: 10))
picker.buttons["Pull Requests"].tap()
picker.buttons["PRs"].tap()
}
/// Resets the overview filter to Open + All involvement (the app defaults).

View file

@ -39,6 +39,7 @@ Forji is available on the Apple App Store for free: [Forji App Store](https://ap
- Create, edit, and close/reopen issues
- Manage labels, milestones, and assignees
- Comment with Markdown support
- Attach and view image and file attachments in descriptions and comments
### Pull Requests
- View PRs across all repositories or per-repo
@ -47,6 +48,7 @@ Forji is available on the Apple App Store for free: [Forji App Store](https://ap
- Submit reviews (comment, approve, request changes)
- Merge with merge commit, rebase, or squash
- Close, reopen, and edit PRs
- Attach and view image and file attachments in descriptions and comments
### Actions
- Browse Forgejo Actions workflows defined in a repository
@ -110,13 +112,13 @@ The Forji logo is based on the [Forgejo logo](https://codeberg.org/forgejo/forge
### Libraries
- [ForgejoKit](https://codeberg.org/secana/ForgejoKit) Forgejo API client (MIT)
- [Textual](https://github.com/gonzalezreal/textual) Markdown rendering (MIT)
- [HighlightSwift](https://github.com/appstefan/HighlightSwift) Code syntax highlighting (MIT)
- [mermaid](https://github.com/mermaid-js/mermaid) Diagram rendering (MIT)
- [marked](https://github.com/markedjs/marked) Markdown parser (MIT)
- [DOMPurify](https://github.com/cure53/DOMPurify) HTML sanitizer (Apache 2.0 / MPL 2.0)
- [github-markdown-css](https://github.com/sindresorhus/github-markdown-css) GitHub-style Markdown styling (MIT)
- [ForgejoKit](https://codeberg.org/secana/ForgejoKit), Forgejo API client (MIT)
- [Textual](https://github.com/gonzalezreal/textual), Markdown rendering (MIT)
- [HighlightSwift](https://github.com/appstefan/HighlightSwift), Code syntax highlighting (MIT)
- [mermaid](https://github.com/mermaid-js/mermaid), Diagram rendering (MIT)
- [marked](https://github.com/markedjs/marked), Markdown parser (MIT)
- [DOMPurify](https://github.com/cure53/DOMPurify), HTML sanitizer (Apache 2.0 / MPL 2.0)
- [github-markdown-css](https://github.com/sindresorhus/github-markdown-css), GitHub-style Markdown styling (MIT)
## Contributing

54
cliff.toml Normal file
View file

@ -0,0 +1,54 @@
# git-cliff configuration — generates CHANGELOG.md from conventional commits.
# Output follows the Keep a Changelog format (https://keepachangelog.com/en/1.1.0/).
# See https://git-cliff.org/docs/configuration
[changelog]
header = """
# Changelog
All notable changes to Forji will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n
"""
body = """
{% if version -%}
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else -%}
## [Unreleased]
{% endif -%}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | upper_first | trim }}\
{% endfor %}
{% endfor %}\n
"""
footer = ""
# Remove leading and trailing whitespaces from the changelog's body.
trim = true
[git]
# Parse commits according to the conventional commits specification.
conventional_commits = true
# Drop commits that are not conventional (e.g. merge commits).
filter_unconventional = true
# Drop commits that no parser assigns to a group.
filter_commits = true
# Only consider tags that look like releases (v1.5, v1.6, ...).
tag_pattern = "v[0-9]*"
sort_commits = "oldest"
# Groups render alphabetically; "Added" < "Changed" < "Fixed" already matches
# the Keep a Changelog ordering for the types we emit.
commit_parsers = [
{ message = "^feat", group = "Added" },
{ message = "^fix", group = "Fixed" },
{ message = "^refactor", group = "Changed" },
{ message = "^perf", group = "Changed" },
{ message = "^docs", group = "Changed" },
{ message = "^chore", skip = true },
{ message = "^test", skip = true },
{ message = "^style", skip = true },
{ message = "^ci", skip = true },
{ message = "^build", skip = true },
]

View file

@ -18,6 +18,7 @@
pkgs.xcbeautify
pkgs.swiftlint
pkgs.swiftformat
pkgs.git-cliff
];
};
});

View file

@ -4,6 +4,7 @@ enum DockerExec {
static func run(
composeFile: String, service: String,
command: [String],
timeout: TimeInterval = 60,
) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
@ -16,8 +17,21 @@ enum DockerExec {
process.standardError = stderrPipe
process.standardOutput = FileHandle.nullDevice
let didExit = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in didExit.signal() }
try process.run()
process.waitUntilExit()
// Enforce a timeout so a hung `docker compose exec` (a known Docker-on-macOS flake)
// fails fast and can be retried, instead of blocking the whole seed indefinitely.
if didExit.wait(timeout: .now() + timeout) == .timedOut {
process.terminate()
_ = didExit.wait(timeout: .now() + 5)
throw SeedError.dockerExecTimedOut(
command: command.joined(separator: " "),
seconds: timeout,
)
}
if process.terminationStatus != 0 {
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()

View file

@ -3,6 +3,10 @@ import Foundation
@main
struct ForgejeSeed {
static func main() async throws {
// Line-buffer stdout so seed progress streams live instead of being held in a
// block buffer (and dumped all at once) when stdout is a pipe rather than a TTY.
setvbuf(stdout, nil, _IOLBF, 0)
let args = CommandLine.arguments
guard args.count >= 3 else {
throw SeedError.missingArguments

View file

@ -3,6 +3,7 @@ import Foundation
enum SeedError: LocalizedError {
case missingArguments
case dockerExecFailed(command: String, exitCode: Int32, stderr: String)
case dockerExecTimedOut(command: String, seconds: TimeInterval)
case retryExhausted(operation: String, attempts: Int)
var errorDescription: String? {
@ -11,6 +12,8 @@ enum SeedError: LocalizedError {
"Usage: forgejo-seed <base-url> <service-name> [compose-file]"
case let .dockerExecFailed(command, exitCode, stderr):
"Docker exec failed (\(exitCode)): \(command)\n\(stderr)"
case let .dockerExecTimedOut(command, seconds):
"Docker exec timed out after \(Int(seconds))s: \(command)"
case let .retryExhausted(operation, attempts):
"\(operation) failed after \(attempts) attempts"
}

View file

@ -25,14 +25,30 @@ struct Seeder {
allowSelfSignedCertificates: true,
)
try createAdminUser()
let start = Date()
let total = 7
func step(_ number: Int, _ label: String) {
let elapsed = Int(Date().timeIntervalSince(start))
print("\n[\(number)/\(total)] \(label) (\(elapsed)s elapsed)")
}
step(1, "Creating admin user")
try await withRetry(maxAttempts: 3, delay: .seconds(2), operation: "create admin user") {
try createAdminUser()
}
step(2, "Creating API users")
try await createAPIUsers(adminClient: adminClient)
step(3, "Creating repositories")
try await createRepositories(adminClient: adminClient)
step(4, "Creating issues, files, and metadata")
try await createIssuesFilesMetadata(adminClient: adminClient, testbotClient: testbotClient)
step(5, "Creating pull requests")
try await createPullRequests(testbotClient: testbotClient)
step(6, "Setting pull request metadata")
try await setPRMetadata(adminClient: adminClient, testbotClient: testbotClient)
step(7, "Creating API token")
try await createAPIToken(adminClient: adminClient)
print("\nSeed data created successfully.")
print("\nSeed data created successfully (\(Int(Date().timeIntervalSince(start)))s total).")
}
// MARK: - Phase 1: Create users

View file

@ -37,11 +37,51 @@ pbxproj := "Forji/Forji.xcodeproj/project.pbxproj"
version:
@grep -m1 'MARKETING_VERSION' {{pbxproj}} | sed 's/.*= *//;s/;.*//'
# Set app version (updates both MARKETING_VERSION and CURRENT_PROJECT_VERSION)
set-version new_version:
sed -i '' 's/MARKETING_VERSION = [^;]*/MARKETING_VERSION = {{new_version}}/' {{pbxproj}}
sed -i '' 's/CURRENT_PROJECT_VERSION = [^;]*/CURRENT_PROJECT_VERSION = {{new_version}}/' {{pbxproj}}
@echo "Version set to {{new_version}}"
changelog := "CHANGELOG.md"
# Preview the changelog entries for unreleased commits (since the latest tag)
changelog:
@git cliff --config cliff.toml --unreleased --strip header
# Release a new version: bump version, generate the changelog, tag, commit, and push
release new_version:
#!/usr/bin/env bash
set -eo pipefail
NEW="{{new_version}}"
TAG="v$NEW"
# Refuse to release from a dirty tree so the release commit stays clean.
if [ -n "$(git status --porcelain)" ]; then
echo "Working tree is not clean. Commit or stash your changes before releasing."
exit 1
fi
# Refuse to clobber an existing tag.
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists."
exit 1
fi
PREV=$(grep -m1 'MARKETING_VERSION' {{pbxproj}} | sed 's/.*= *//;s/;.*//')
echo "Releasing $PREV -> $NEW"
# 1. Bump the version in the Xcode project. Use a backup suffix so the -i
# syntax works on both BSD sed (macOS) and GNU sed (nix dev shell).
sed -i.bak "s/MARKETING_VERSION = [^;]*/MARKETING_VERSION = $NEW/" {{pbxproj}}
sed -i.bak "s/CURRENT_PROJECT_VERSION = [^;]*/CURRENT_PROJECT_VERSION = $NEW/" {{pbxproj}}
rm -f {{pbxproj}}.bak
# 2. Generate the changelog section for the new version from the conventional
# commits since the last tag, prepending it under the header.
git cliff --config cliff.toml --unreleased --tag "$TAG" --prepend {{changelog}}
# 3. Commit, tag, and push.
git add {{pbxproj}} {{changelog}}
git commit -m "chore: release $NEW"
git tag -a "$TAG" -m "Release $NEW"
git push origin HEAD
git push origin "$TAG"
echo "Released $NEW and pushed $TAG."
# Clean build artifacts
clean: