Compare commits

...

33 commits

Author SHA1 Message Date
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
Stefan Hausotte
0bcb397805 chore: bump Forji version to 1.5 2026-06-04 14:39:31 +02:00
Stefan Hausotte
330d2bde4f fix: remove redundant notification Dismiss swipe action (#46)
The trailing Dismiss swipe called the same setNotificationRead path as the
leading Mark Read action, since ForgejoKit's NotificationService only exposes
markAsRead (PATCH to-status=read). Dismiss therefore did nothing distinct and
left the row visible under the All/Read filters, just relabeled.

Drop the redundant action in both NotificationsOverviewView and
MergedNotificationsOverviewView. The leading Mark Read swipe (shown for unread
threads, full-swipe by default) remains the single, honest action.

Reviewed-on: https://codeberg.org/secana/Forji/pulls/64
2026-06-04 14:08:20 +02:00
Stefan Hausotte
745b7af45b fix: persist instance removal before deleting keychain on logout (#48)
logout deleted keychain credentials first, then removed the SwiftData instance
with a swallowed `try? modelContext.save()`. If the save failed, the instance
survived with its credentials gone, leaving an account that could not
authenticate. A `nil` default modelContext also let the instance removal be
skipped entirely while credentials were still wiped.

Reorder to delete + save the SwiftData instance first (matching
InstanceListView.deleteInstance) and only delete credentials once that succeeds;
bail out on save failure so the account stays usable. Make modelContext
required to remove the latent orphan path (all call sites already pass one).

Reviewed-on: https://codeberg.org/secana/Forji/pulls/65
2026-06-04 14:08:07 +02:00
Stefan Hausotte
d95ec8ddc5 fix: refresh issue/PR detail after a partial edit failure (#50)
IssueEditView and PullRequestEditView save by running several API calls in
sequence (replaceLabels, editIssue, editPullRequest, requestReviewers,
removeReviewers). Forgejo has no transaction, so if a later call fails the
earlier ones stay committed, yet the catch block only showed an error and never
fired onSaved, leaving the detail view with stale data.

On failure, re-fetch the issue/PR and push the authoritative server state via
onSaved (without dismissing, so the user can retry) before surfacing the error.
True rollback is not possible against the REST API; reflecting the real
committed state is the correct remedy.

Reviewed-on: https://codeberg.org/secana/Forji/pulls/66
2026-06-04 14:07:53 +02:00
secana
8e7f99b6e3 fix: settle merged pagination and drop duplicate rows (#47) 2026-06-04 14:07:32 +02:00
Stefan Hausotte
bec427d7da fix: settle merged pagination and drop duplicate rows (#47)
PaginationState computed hasMore as `fetched.count >= pageSize`, but merged
views return the combined, de-duplicated result of N instances, so the merged
count cleared the single-source threshold and kept hasMore true past the real
end (extra empty fetches). loadMore also appended without de-duping against
already-loaded items, so overlapping involvement queries and shifting pages
could produce duplicate TaggedItem.id entries in the ForEach.

Add an optional dedupeKey to PaginationState that de-duplicates across pages via
a running seen-set, and base hasMore on whether a page contributed new items
when dedupeKey is set. Single-source pagination keeps the page-size heuristic
unchanged. Merged Issues/PRs, Repositories, and Notifications views opt in with
dedupeKey = { $0.id }.
2026-06-04 13:50:40 +02:00
Stefan Hausotte
66fe573cb6 chore: update ForgejoKit dependency to 0.7.0 2026-06-04 13:10:43 +02:00
systemBlue
c2454d3444 docs: add CONTRIBUTING.md (#42) 2026-06-04 13:01:59 +02:00
systemBlue
ed2051ae5d docs: fix typo in README App Store line (#43) 2026-06-04 13:01:41 +02:00
systemBlue
cbd4039e40 feat: add VoiceOver accessibility labels to icon-only buttons (#49) 2026-06-04 13:01:22 +02:00
systemBlue
e9b6be91f5 fix: don't open a duplicate PR when the reviewer request fails (#53) 2026-06-04 13:00:44 +02:00
systemBlue
4f6803cc03 fix: don't crash the background notification poll on an invalid instance URL (#54) 2026-06-04 13:00:25 +02:00
systemBlue
639812348b fix: refresh the merged issue/PR overview after a mutation (#56) 2026-06-04 12:59:59 +02:00
systemBlue
4ef76cf2d7 fix: update the app icon badge in multi-instance mode (#58) 2026-06-04 12:59:43 +02:00
systemBlue
8e56b9d722 feat: make tappable diff lines act as buttons for VoiceOver (#61) 2026-06-04 12:59:17 +02:00
Stefan Hausotte
b0f50eca38 Merge branch 'main' of ssh://codeberg.org/secana/Forji 2026-06-02 18:21:07 +02:00
systemBlue
31d9aeea35 fix: Delete the API token, not just the password, when removing an account (#40)
`deleteInstance` cleared only the keychain password, never the token (`logout` deletes both), so swipe-to-delete left a live API token orphaned; this routes both paths through a new `KeychainManager.deleteCredentials` and adds regression tests (186 ForjiTests green).

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/40
2026-06-02 18:20:43 +02:00
Stefan Hausotte
8648f40c07 fix: guard that prevents edit of accounts to add the same account 2026-06-02 18:20:12 +02:00
systemBlue
89a0cd0bb2 fix: crash merged overviews when two accounts share a sourceKey (#39)
Adding the same server and username twice gives two connections one `sourceKey`, and `rehydrate`'s `Dictionary(uniqueKeysWithValues:)` traps on it, crashing every merged overview on construction; this keeps the first source and adds a regression test (185 ForjiTests green).

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/39
2026-06-02 18:05:53 +02:00
systemBlue
c6dda2cd70 fix: read messages not marked read
Fixes #32. Opening a notification now marks its thread read (via the detail's `.onAppear`) so the tab and icon badges clear; swipe actions unchanged. Adds a UI regression test covering open-to-clear.

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.
2026-06-02 18:01:07 +02:00
52 changed files with 967 additions and 189 deletions

41
CHANGELOG.md Normal file
View file

@ -0,0 +1,41 @@
# 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.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.

77
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,77 @@
# Contributing to Forji
Thanks for your interest in contributing! Forji is a native iOS client for
Forgejo, and contributions are welcome. These guidelines keep changes easy to
review and the codebase easy to maintain for the next person.
## Pull requests
- **One concern per pull request.** Keep each PR focused on a single change.
Split bug fixes out from refactors. A behavior-preserving refactor is easy to
approve on its own; bundling it with a fix makes the actual fix hard to
isolate and review.
- **Keep the description accurate.** The PR description must match what the diff
actually does. If you change the approach while iterating, update the
description to match the final code, and explain *why* the change fixes the
problem.
- **Prefer small, stacked PRs** over one large change. For anything large, open
an issue first so we can agree on the shape before you write the code.
- Respond to review feedback by fixing it or explaining it.
## Reporting bugs
Open an issue with exact reproduction steps so the bug can actually be
reproduced and fixed:
- Device and iOS version.
- What to tap and what to watch for.
- Expected behavior vs. what actually happens.
## Architecture
New code follows MVVM with Apple-native tools:
- `@Observable` view models with `async/await`.
- **No external state-management dependency.** The project deliberately stays on
Apple's `@Observable` + `async/await` direction rather than adopting a
third-party architecture.
- Keep the code easy to understand for the next contributor. Favor the simplest
approach that fits Apple's idioms.
- ForgejoKit holds the platform-agnostic API logic; the Forji app holds the
SwiftUI views, authentication, and persistence. Keep that separation.
## Code style
The repository ships its own formatter and linter configs
([`.swiftformat`](.swiftformat), [`.swiftlint.yml`](.swiftlint.yml)). Match them
before opening a PR, and match the existing style in the files you touch:
```bash
just format # SwiftFormat
just lint # SwiftLint
```
## Tests
- Include unit tests for the behavior you add or change.
- When you fix a UI behavior that has no automated coverage, add a UI regression
test. If a change is hard to cover, say so in the PR and offer a UI test.
- See the [README](README.md) for `just test`, `just test-ui`, and the
integration test setup.
## License and the App Store exception
Forji is licensed under the [GNU General Public License v3.0](LICENSE) with an
[App Store Exception](LICENSE#app-store-exception) that permits distribution of
the compiled app through Apple's App Store.
By submitting a pull request you agree your contribution is licensed under
GPLv3 and may be distributed through the App Store under that exception. To
confirm, include this sentence in your PR description or a comment:
> "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."
You keep your copyright, the project stays open source under GPLv3, and you will
be credited as a contributor.

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.4;
CURRENT_PROJECT_VERSION = 1.5;
DEVELOPMENT_TEAM = RVT2M7QTD4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -454,7 +454,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4;
MARKETING_VERSION = 1.5;
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.4;
CURRENT_PROJECT_VERSION = 1.5;
DEVELOPMENT_TEAM = RVT2M7QTD4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -490,7 +490,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4;
MARKETING_VERSION = 1.5;
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.4;
CURRENT_PROJECT_VERSION = 1.5;
DEVELOPMENT_TEAM = RVT2M7QTD4;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.4;
MARKETING_VERSION = 1.5;
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.4;
CURRENT_PROJECT_VERSION = 1.5;
DEVELOPMENT_TEAM = RVT2M7QTD4;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.4;
MARKETING_VERSION = 1.5;
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.4;
CURRENT_PROJECT_VERSION = 1.5;
DEVELOPMENT_TEAM = RVT2M7QTD4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.4;
MARKETING_VERSION = 1.5;
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.4;
CURRENT_PROJECT_VERSION = 1.5;
DEVELOPMENT_TEAM = RVT2M7QTD4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.4;
MARKETING_VERSION = 1.5;
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.6.1;
version = 0.7.0;
};
};
DEC49F6B2F3D00C700E7DD54 /* XCRemoteSwiftPackageReference "textual" */ = {

View file

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

View file

@ -59,6 +59,8 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
if response.actionIdentifier == "MARK_READ" {
await markNotificationAsRead(userInfo: userInfo, serverURL: serverURL, username: username)
} else {
// Opening the notification clears it, the same as tapping it inside the app.
await markNotificationAsRead(userInfo: userInfo, serverURL: serverURL, username: username)
await MainActor.run {
NavigationState.shared.navigateToNotifications(serverURL: serverURL, username: username)
}

View file

@ -9,20 +9,29 @@ 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. 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>()
init(pageSize: Int = 20) {
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
@ -37,13 +46,16 @@ final class PaginationState<Item> {
hasLoaded = false
isLoading = true
showError = false
notFound = false
let pageSize = pageSize
let task = Task {
do {
let fetched = try await fetch(1, pageSize)
guard !Task.isCancelled else { return }
items = fetched
hasMore = fetched.count >= pageSize
if dedupeKey != nil { seenKeys.removeAll() }
let newItems = deduped(fetched)
items = newItems
hasMore = computeHasMore(fetched: fetched, newItems: newItems)
currentPage = 2
hasLoaded = true
} catch is CancellationError {
@ -72,8 +84,9 @@ final class PaginationState<Item> {
do {
let fetched = try await fetch(currentPage, pageSize)
guard !Task.isCancelled else { return }
items.append(contentsOf: fetched)
hasMore = fetched.count >= pageSize
let newItems = deduped(fetched)
items.append(contentsOf: newItems)
hasMore = computeHasMore(fetched: fetched, newItems: newItems)
self.currentPage = currentPage + 1
} catch is CancellationError {
// Ignore cancellation
@ -95,6 +108,24 @@ final class PaginationState<Item> {
func invalidate() {
hasLoaded = false
}
/// Filters out items already seen on a previous page when `dedupeKey` is set,
/// recording the new keys. Without a `dedupeKey` the page passes through
/// unchanged so single-source pagination keeps its existing behavior.
private func deduped(_ fetched: [Item]) -> [Item] {
guard let dedupeKey else { return fetched }
return fetched.filter { seenKeys.insert(dedupeKey($0)).inserted }
}
/// For merged sources the de-duplicated, multi-instance page size no longer
/// matches `pageSize`, so "more pages exist" means "this page contributed new
/// items." Single sources keep the page-size heuristic.
private func computeHasMore(fetched: [Item], newItems: [Item]) -> Bool {
if dedupeKey != nil {
return !newItems.isEmpty
}
return fetched.count >= pageSize
}
}
// MARK: - Disk cache

View file

@ -73,8 +73,11 @@ extension PaginationState where Item: Identifiable {
/// Replace placeholder auth services using the given connection sources.
/// Items whose sourceKey has no matching source are removed.
func rehydrate<T>(from sources: [ConnectionSource]) where Item == TaggedItem<T> {
// Two connected accounts with the same server+username share a sourceKey,
// so keep the first rather than trapping on a duplicate key.
let lookup = Dictionary(
uniqueKeysWithValues: sources.map { ($0.sourceKey, $0.authService) },
sources.map { ($0.sourceKey, $0.authService) },
uniquingKeysWith: { first, _ in first },
)
items = items.compactMap { item in
guard let auth = lookup[item.sourceKey] else { return nil }

View file

@ -95,15 +95,23 @@ class AuthenticationService {
return true
}
func logout(modelContext: ModelContext? = nil) async {
func logout(modelContext: ModelContext) async {
if let instance = currentInstance {
let normalizedURL = ForgejoClient.normalizeServerURL(instance.serverURL)
// Remove the instance from SwiftData first. If the save fails, keep the
// credentials so the account stays usable instead of becoming an
// unauthenticatable orphan, and bail out without clearing the session.
instance.isDefault = false
modelContext.delete(instance)
do {
try await KeychainManager.shared.deletePassword(
for: normalizedURL,
username: instance.username,
)
try await KeychainManager.shared.deleteToken(
try modelContext.save()
} catch {
assertionFailure("SwiftData save failed during logout: \(error)")
return
}
// Delete credentials only after the instance is gone from SwiftData.
do {
try await KeychainManager.shared.deleteCredentials(
for: normalizedURL,
username: instance.username,
)
@ -113,12 +121,6 @@ class AuthenticationService {
print("Keychain delete failed during logout: \(error)")
#endif
}
// Remove the instance from SwiftData and unset default
if let modelContext {
instance.isDefault = false
modelContext.delete(instance)
try? modelContext.save()
}
}
// Clears cache for all instances since keys are hashed and cannot be scoped per-instance
DiskCache.removeAll()

View file

@ -87,7 +87,9 @@ final class BackgroundDownloadManager: NSObject, Sendable {
for: instance.serverURL, username: instance.username,
) else { return nil }
var components = URLComponents(string: "\(instance.serverURL)/api/v1/notifications")!
guard var components = URLComponents(string: "\(instance.serverURL)/api/v1/notifications") else {
return nil
}
components.queryItems = [
URLQueryItem(name: "status-types", value: "unread"),
URLQueryItem(name: "page", value: "1"),

View file

@ -40,6 +40,15 @@ actor KeychainManager {
try deleteItem(forKey: key(for: server, username: username, suffix: "_token"))
}
// MARK: - Account (both items)
/// Removes every credential stored for an account: the password and the API token.
/// Use when an account is removed entirely (logout, delete instance) so neither item is orphaned.
func deleteCredentials(for server: String, username: String) throws {
try deletePassword(for: server, username: username)
try deleteToken(for: server, username: username)
}
// MARK: - Synchronous token read (no actor hop)
nonisolated static func getTokenSync(for server: String, username: String) -> String? {

View file

@ -36,6 +36,7 @@ struct CommentView: View {
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Edit comment")
}
}

View file

@ -60,8 +60,10 @@ struct DiffView: View {
}
}
@ViewBuilder
private func diffLineRow(line: DiffLine, filePath: String) -> some View {
HStack(spacing: 0) {
let isCommentable = onLineTap != nil && line.type != .header && line.diffPosition != nil
let row = HStack(spacing: 0) {
HStack(spacing: 2) {
Text(line.oldLineNumber.map { "\($0)" } ?? "")
.font(.system(.caption2, design: .monospaced))
@ -88,7 +90,7 @@ struct DiffView: View {
Spacer(minLength: 0)
if onLineTap != nil, line.type != .header, line.diffPosition != nil {
if isCommentable {
Image(systemName: "plus.bubble")
.font(.caption2)
.foregroundStyle(.tertiary)
@ -100,10 +102,18 @@ struct DiffView: View {
.padding(.vertical, 1)
.background(line.type.backgroundColor)
.contentShape(Rectangle())
.onTapGesture {
if line.type != .header, line.diffPosition != nil {
// A tappable line is a Button so VoiceOver announces it and can activate it.
if isCommentable {
Button {
onLineTap?(line, filePath)
} label: {
row
}
.buttonStyle(.plain)
.accessibilityHint("Add a comment on this line")
} else {
row
}
}

View file

@ -113,6 +113,7 @@ struct FileViewerView: View {
} label: {
Image(systemName: "pencil")
}
.accessibilityLabel("Edit file")
.accessibilityIdentifier("file-edit-button")
}
}

View file

@ -11,6 +11,7 @@ struct FloatingCreateButton: View {
.frame(width: 56, height: 56)
}
.glassEffect(.regular.tint(.blue).interactive())
.accessibilityLabel("Create")
.padding(.trailing, 20)
.padding(.bottom, 20)
}
@ -36,6 +37,7 @@ struct ExpandableActionMenu<Content: View>: View {
.frame(width: 40, height: 40)
}
.buttonStyle(.glass)
.accessibilityLabel("Actions")
.accessibilityIdentifier("action-menu-toggle")
.accessibilityValue(isExpanded ? "expanded" : "collapsed")
}

View file

@ -99,11 +99,15 @@ struct HomeView: View {
} else if let notificationService {
do {
unreadCount = try await notificationService.fetchUnreadCount()
try? await UNUserNotificationCenter.current().setBadgeCount(unreadCount)
} catch {
// Silently ignore badge is non-critical
// Silently ignore badge is non-critical, keep the prior count
return
}
} else {
return
}
// Mirror the in-app tab badge to the system app icon badge in both modes
try? await UNUserNotificationCenter.current().setBadgeCount(unreadCount)
}
}

View file

@ -263,15 +263,24 @@ struct InstanceFormView: View {
case .add:
try await performLogin()
// Resolve the account identity after login: token auth learns the
// real username from the server, so it isn't known before this point.
let normalizedURL = ForgejoClient.normalizeServerURL(serverURL)
let resolvedUsername =
authMode == .token ? (authService.currentUser?.login ?? username) : username
// Two accounts sharing a server+username collide on the same sourceKey,
// which used to crash merged overviews. Reject the duplicate up front.
if rejectDuplicateAccount(serverURL: normalizedURL, username: resolvedUsername) {
return
}
if isDefault {
for inst in instances where inst.isDefault {
inst.isDefault = false
}
}
let normalizedURL = ForgejoClient.normalizeServerURL(serverURL)
let resolvedUsername =
authMode == .token ? (authService.currentUser?.login ?? username) : username
let instance = ForgejoInstance(
serverURL: normalizedURL,
username: resolvedUsername,
@ -289,16 +298,30 @@ struct InstanceFormView: View {
authService.currentInstance = instance
case let .edit(instance):
// Resolve the would-be identity before mutating the instance: token
// auth may change the username, and credentials auth may too.
var resolvedUsername = instance.username
if authMode == .token, !apiToken.isEmpty {
try await performLogin()
instance.username = authService.currentUser?.login ?? username
resolvedUsername = authService.currentUser?.login ?? username
} else if authMode == .credentials, !password.isEmpty {
try await performLogin()
instance.username = username
resolvedUsername = username
}
let normalizedURL = ForgejoClient.normalizeServerURL(serverURL)
// Editing must not produce a server+username that already belongs to
// another account, since that collides on the same sourceKey.
if rejectDuplicateAccount(
serverURL: normalizedURL, username: resolvedUsername, excluding: instance,
) {
return
}
instance.username = resolvedUsername
instance.name = name
instance.serverURL = ForgejoClient.normalizeServerURL(serverURL)
instance.serverURL = normalizedURL
instance.allowSelfSignedCertificates = allowSelfSignedCertificates
instance.useTokenAuth = authMode == .token
@ -328,6 +351,36 @@ struct InstanceFormView: View {
isLoading = false
}
}
/// Sets the duplicate-account error and returns true if another stored account
/// already uses this server+username. `excluding` skips the instance being edited.
private func rejectDuplicateAccount(
serverURL normalizedURL: String,
username: String,
excluding: ForgejoInstance? = nil,
) -> Bool {
guard Self.accountAlreadyExists(
serverURL: normalizedURL, username: username, in: instances, excluding: excluding,
) else { return false }
errorMessage = "An account for \"\(username)\" on \(normalizedURL) already exists."
showError = true
isLoading = false
return true
}
/// True if `instances` already contains an account with this server+username,
/// ignoring `excluding` (the instance being edited). Accounts that share a
/// server+username collide on the same sourceKey, which crashes merged overviews.
static func accountAlreadyExists(
serverURL normalizedURL: String,
username: String,
in instances: [ForgejoInstance],
excluding: ForgejoInstance? = nil,
) -> Bool {
instances.contains { inst in
inst !== excluding && inst.serverURL == normalizedURL && inst.username == username
}
}
}
#if DEBUG

View file

@ -79,6 +79,7 @@ struct InstanceListView: View {
} label: {
Image(systemName: "plus")
}
.accessibilityLabel("Add instance")
.accessibilityIdentifier("instance-add-button")
}
}
@ -129,6 +130,7 @@ struct InstanceListView: View {
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Edit instance")
.accessibilityIdentifier("instance-edit-button")
}
}
@ -223,7 +225,7 @@ struct InstanceListView: View {
assertionFailure("SwiftData save failed: \(error)")
}
Task {
try? await KeychainManager.shared.deletePassword(for: normalizedURL, username: username)
try? await KeychainManager.shared.deleteCredentials(for: normalizedURL, username: username)
}
}

View file

@ -127,6 +127,14 @@ struct IssueEditView: View {
onSaved(updatedIssue)
dismiss()
} catch {
// The calls above run in sequence with no transaction, so an earlier
// one may already be committed on the server. Re-fetch the issue and
// push the authoritative state to the detail view so it does not show
// stale data, then surface the error and keep the sheet open to retry.
if let refreshed = try? await issueService.fetchIssue(owner: owner, repo: repo, index: issue.number) {
NotificationCenter.default.post(name: .issuesDidChange, object: nil)
onSaved(refreshed)
}
errorMessage = error.localizedDescription
showError = true
}

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(
@ -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,12 @@ 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 +172,18 @@ struct IssueListView: View {
)
}
}
#Preview("Issues Unavailable") {
NavigationStack {
IssueListView(
preview: (),
repository: .preview,
authService: .previewDefault,
issues: [],
notFound: true,
)
}
}
#endif
struct IssueRow: View {

View file

@ -64,22 +64,23 @@ private struct MarkdownToolbar: View {
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
toolbarButton("bold", icon: "bold") { wrap("**") }
toolbarButton("italic", icon: "italic") { wrap("_") }
toolbarButton("heading", icon: "number") { prefix("# ") }
toolbarButton("code", icon: "chevron.left.forwardslash.chevron.right") { wrap("`") }
toolbarButton("codeblock", icon: "text.page") { wrapBlock("```") }
toolbarButton("link", icon: "link") { insertLink() }
toolbarButton("list", icon: "list.bullet") { prefix("- ") }
toolbarButton("quote", icon: "text.quote") { prefix("> ") }
toolbarButton("task", icon: "checklist") { prefix("- [ ] ") }
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, action: @escaping () -> Void) -> some View {
private func toolbarButton(_ id: String, icon: String, label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.subheadline)
@ -88,6 +89,7 @@ private struct MarkdownToolbar: View {
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.accessibilityLabel(label)
.accessibilityIdentifier("markdown-toolbar-\(id)")
}

View file

@ -6,11 +6,13 @@ 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
let state = PaginationState<TaggedItem<NotificationThread>>()
state.cacheName = "merged_notifications"
state.dedupeKey = { $0.id }
state.loadFromCache()
state.rehydrate(from: manager)
_pagination = State(initialValue: state)
@ -66,14 +68,6 @@ struct MergedNotificationsOverviewView: View {
.tint(.blue)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
Task { await dismissNotification(tagged) }
} label: {
Label("Dismiss", systemImage: "xmark")
}
.tint(.gray)
}
}
if pagination.hasMore {
@ -102,6 +96,9 @@ struct MergedNotificationsOverviewView: View {
.onChange(of: statusFilter) {
reloadNotifications(clearItems: true)
}
.onChange(of: navigationState.notificationsRefreshTrigger) {
reloadNotifications()
}
.errorAlert(message: $pagination.errorMessage, isPresented: $pagination.showError)
}
@ -110,7 +107,10 @@ struct MergedNotificationsOverviewView: View {
let notification = tagged.item
let destination = navigationDestination(for: tagged)
if let destination {
NavigationLink { destination } label: {
NavigationLink {
destination
.onAppear { Task { await markReadOnOpen(tagged) } }
} label: {
MergedNotificationRow(notification: notification, instanceName: tagged.instanceName)
}
} else {
@ -189,18 +189,22 @@ struct MergedNotificationsOverviewView: View {
await setNotificationRead(tagged)
}
private func dismissNotification(_ tagged: TaggedItem<NotificationThread>) async {
await setNotificationRead(tagged)
/// Marks a notification read when the user opens it, mirroring Mail and News.
/// The row is updated in place rather than removed so navigation into the
/// detail view is not interrupted while the list is mutating.
private func markReadOnOpen(_ tagged: TaggedItem<NotificationThread>) async {
guard tagged.item.unread else { return }
await setNotificationRead(tagged, removeFromUnread: false)
}
private func setNotificationRead(_ tagged: TaggedItem<NotificationThread>) async {
private func setNotificationRead(_ tagged: TaggedItem<NotificationThread>, removeFromUnread: Bool = true) async {
guard let client = tagged.authService.client else { return }
let service = NotificationService(client: client)
let notification = tagged.item
do {
try await service.markAsRead(id: notification.id)
withAnimation {
if statusFilter == "unread" {
if removeFromUnread, statusFilter == "unread" {
pagination.items.removeAll { $0.id == tagged.id }
} else if let index = pagination.items.firstIndex(where: { $0.id == tagged.id }) {
let updated = NotificationThread(
@ -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

@ -14,6 +14,7 @@ struct MergedRepositoryListView: View {
self.manager = manager
let state = PaginationState<TaggedItem<Repository>>()
state.cacheName = "merged_repos"
state.dedupeKey = { $0.id }
state.loadFromCache()
state.rehydrate(from: manager)
_pagination = State(initialValue: state)

View file

@ -68,6 +68,7 @@ struct MergedSearchableOverviewView<Row: View, Detail: View, CreateView: View>:
let state = PaginationState<TaggedItem<Issue>>()
state.cacheName = "merged_\(config.issueType)"
state.dedupeKey = { $0.id }
state.loadFromCache()
state.rehydrate(from: manager)
_pagination = State(initialValue: state)
@ -161,6 +162,14 @@ struct MergedSearchableOverviewView<Row: View, Detail: View, CreateView: View>:
reloadItems()
}
}
.onReceive(
NotificationCenter.default.publisher(
for: config.issueType == "pulls" ? .pullRequestsDidChange : .issuesDidChange,
),
) { _ in
pagination.invalidate()
reloadItems()
}
.onChange(of: stateFilter) {
reloadItems(clearItems: true)
}

View file

@ -68,14 +68,6 @@ struct NotificationsOverviewView: View {
.tint(.blue)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
Task { await dismissNotification(notification) }
} label: {
Label("Dismiss", systemImage: "xmark")
}
.tint(.gray)
}
}
if pagination.hasMore {
@ -114,7 +106,10 @@ struct NotificationsOverviewView: View {
private func notificationRow(_ notification: NotificationThread) -> some View {
let destination = navigationDestination(for: notification)
if let destination {
NavigationLink { destination } label: {
NavigationLink {
destination
.onAppear { Task { await markReadOnOpen(notification) } }
} label: {
NotificationRow(notification: notification)
}
} else {
@ -178,16 +173,20 @@ struct NotificationsOverviewView: View {
await setNotificationRead(notification)
}
private func dismissNotification(_ notification: NotificationThread) async {
await setNotificationRead(notification)
/// Marks a notification read when the user opens it, mirroring Mail and News.
/// The row is updated in place rather than removed so navigation into the
/// detail view is not interrupted while the list is mutating.
private func markReadOnOpen(_ notification: NotificationThread) async {
guard notification.unread else { return }
await setNotificationRead(notification, removeFromUnread: false)
}
private func setNotificationRead(_ notification: NotificationThread) async {
private func setNotificationRead(_ notification: NotificationThread, removeFromUnread: Bool = true) async {
guard let notificationService else { return }
do {
try await notificationService.markAsRead(id: notification.id)
withAnimation {
if statusFilter == "unread" {
if removeFromUnread, statusFilter == "unread" {
pagination.items.removeAll { $0.id == notification.id }
} else if let index = pagination.items.firstIndex(where: { $0.id == notification.id }) {
pagination.items[index] = NotificationThread(
@ -250,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()
@ -265,7 +268,6 @@ private struct NotificationRow: View {
Circle()
.fill(.blue)
.frame(width: 10, height: 10)
.glassEffect(.regular.tint(.blue))
.padding(.top, 6)
}
}

View file

@ -20,6 +20,7 @@ struct PullRequestCreateView: View {
@State private var selectedMilestoneID: Int?
@State private var selectedAssigneeLogins: Set<String> = []
@State private var selectedReviewerLogins: Set<String> = []
@State private var createdPRNumber: Int?
@Environment(\.dismiss) private var dismiss
private let prService: PullRequestService?
@ -175,24 +176,33 @@ struct PullRequestCreateView: View {
guard let prService else { return }
isSubmitting = true
do {
let trimmedBody = bodyText.trimmingCharacters(in: .whitespacesAndNewlines)
let createdPR = try await prService.createPullRequest(
owner: owner,
repo: repo,
title: title.trimmingCharacters(in: .whitespacesAndNewlines),
head: headBranch,
base: baseBranch,
body: trimmedBody.isEmpty ? nil : trimmedBody,
labels: selectedLabelIDs.isEmpty ? nil : Array(selectedLabelIDs),
milestone: selectedMilestoneID,
assignees: selectedAssigneeLogins.isEmpty ? nil : Array(selectedAssigneeLogins),
)
// Reuse the already-created PR on retry so a failed reviewer request
// doesn't open a duplicate PR when the user taps Create again.
let prNumber: Int
if let existingNumber = createdPRNumber {
prNumber = existingNumber
} else {
let trimmedBody = bodyText.trimmingCharacters(in: .whitespacesAndNewlines)
let createdPR = try await prService.createPullRequest(
owner: owner,
repo: repo,
title: title.trimmingCharacters(in: .whitespacesAndNewlines),
head: headBranch,
base: baseBranch,
body: trimmedBody.isEmpty ? nil : trimmedBody,
labels: selectedLabelIDs.isEmpty ? nil : Array(selectedLabelIDs),
milestone: selectedMilestoneID,
assignees: selectedAssigneeLogins.isEmpty ? nil : Array(selectedAssigneeLogins),
)
createdPRNumber = createdPR.number
prNumber = createdPR.number
}
if !selectedReviewerLogins.isEmpty {
do {
try await prService.requestReviewers(
owner: owner,
repo: repo,
index: createdPR.number,
index: prNumber,
reviewers: Array(selectedReviewerLogins),
)
} catch {

View file

@ -173,6 +173,16 @@ struct PullRequestEditView: View {
onSaved(updatedPR)
dismiss()
} catch {
// The calls above run in sequence with no transaction, so an earlier
// one may already be committed on the server. Re-fetch the PR and push
// the authoritative state to the detail view so it does not show stale
// data, then surface the error and keep the sheet open to retry.
if let refreshed = try? await prService.fetchPullRequest(
owner: owner, repo: repo, index: pullRequest.number,
) {
NotificationCenter.default.post(name: .pullRequestsDidChange, object: nil)
onSaved(refreshed)
}
errorMessage = error.localizedDescription
showError = true
}

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(
@ -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,12 @@ 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 +172,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 {
@ -211,6 +211,7 @@ struct RepositoryDetailView: View {
} label: {
Image(systemName: "clock.arrow.circlepath")
}
.accessibilityLabel("Commit history")
.accessibilityIdentifier("commits-button")
}
}

View file

@ -280,6 +280,7 @@ struct RepositoryRow: View {
}
.buttonStyle(.borderless)
.disabled(isStarring)
.accessibilityLabel(isStarred ? "Unstar repository" : "Star repository")
.accessibilityIdentifier("star-button")
}

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)
}
@ -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

@ -48,6 +48,7 @@ struct WorkflowRunDetailView: View {
Link(destination: parsed) {
Image(systemName: "safari")
}
.accessibilityLabel("Open in browser")
.accessibilityIdentifier("workflow-run-open-browser")
}
}

View file

@ -0,0 +1,62 @@
import Foundation
import Testing
@testable import Forji
@MainActor
struct InstanceFormViewTests {
private func makeInstance(
server: String = "https://a.com",
username: String = "user",
name: String = "Account",
) -> ForgejoInstance {
ForgejoInstance(serverURL: server, username: username, name: name)
}
// MARK: - accountAlreadyExists
@Test func detectsDuplicateServerAndUsername() {
let existing = [makeInstance(server: "https://a.com", username: "user", name: "Work")]
#expect(InstanceFormView.accountAlreadyExists(
serverURL: "https://a.com", username: "user", in: existing,
))
}
@Test func differentServerIsNotDuplicate() {
let existing = [makeInstance(server: "https://a.com", username: "user")]
#expect(!InstanceFormView.accountAlreadyExists(
serverURL: "https://b.com", username: "user", in: existing,
))
}
@Test func differentUsernameIsNotDuplicate() {
let existing = [makeInstance(server: "https://a.com", username: "user")]
#expect(!InstanceFormView.accountAlreadyExists(
serverURL: "https://a.com", username: "other", in: existing,
))
}
@Test func emptyListIsNeverDuplicate() {
#expect(!InstanceFormView.accountAlreadyExists(
serverURL: "https://a.com", username: "user", in: [],
))
}
// MARK: - excluding (edit path)
@Test func editingInstanceWithoutChangingIdentityIsAllowed() {
// Editing only the name on an existing account must not flag itself as a duplicate.
let instance = makeInstance(server: "https://a.com", username: "user", name: "Old")
#expect(!InstanceFormView.accountAlreadyExists(
serverURL: "https://a.com", username: "user", in: [instance], excluding: instance,
))
}
@Test func editingIntoAnotherAccountsIdentityIsRejected() {
// Two distinct accounts; editing the second to match the first must be caught.
let first = makeInstance(server: "https://a.com", username: "user", name: "First")
let second = makeInstance(server: "https://b.com", username: "user", name: "Second")
#expect(InstanceFormView.accountAlreadyExists(
serverURL: "https://a.com", username: "user", in: [first, second], excluding: second,
))
}
}

View file

@ -133,4 +133,40 @@ struct KeychainManagerTests {
// Clean up
try await KeychainManager.shared.deletePassword(for: normalized, username: "user")
}
@Test func keychainDeleteCredentialsRemovesBothPasswordAndToken() async throws {
let server = "https://delete-credentials.example.com"
let username = "user"
try await KeychainManager.shared.savePassword("mypassword", for: server, username: username)
try await KeychainManager.shared.saveToken("mytoken", for: server, username: username)
try await KeychainManager.shared.deleteCredentials(for: server, username: username)
do {
_ = try await KeychainManager.shared.getPassword(for: server, username: username)
Issue.record("Expected KeychainError.notFound for password after deleteCredentials")
} catch is KeychainError {
// Expected
} catch {
Issue.record("Unexpected error: \(error)")
}
do {
_ = 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
} catch {
Issue.record("Unexpected error: \(error)")
}
}
@Test func keychainDeleteCredentialsNonExistentDoesNotThrow() async throws {
// Deleting credentials for an account with nothing stored must not throw.
try await KeychainManager.shared.deleteCredentials(
for: "https://nonexistent-credentials.example.com",
username: "nobody"
)
}
}

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,3 +1,4 @@
// swiftlint:disable file_length
import Foundation
import Testing
@testable import Forji
@ -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"]
}
@ -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

@ -190,4 +190,33 @@ struct TaggedItemTests {
#expect(pagination.items.isEmpty)
}
@MainActor
@Test func rehydrateHandlesDuplicateSourceKeys() {
// Two connected accounts with the same server+username produce duplicate
// sourceKeys; rehydrate must not trap on the duplicate key.
let liveAuth = AuthenticationService()
let key = "https://a.com:u"
let sources = [
ConnectionSource(
sourceKey: key, name: "A",
client: ForgejoClient(serverURL: "https://a.com", username: "u", token: "t"),
authService: liveAuth,
),
ConnectionSource(
sourceKey: key, name: "A (duplicate)",
client: ForgejoClient(serverURL: "https://a.com", username: "u", token: "t"),
authService: AuthenticationService(),
),
]
let pagination = PaginationState<TaggedItem<FIssue>>()
pagination.items = [
TaggedItem(item: makeIssue(id: 1), sourceKey: key, instanceName: "A", authService: makeAuth()),
]
pagination.rehydrate(from: sources)
#expect(pagination.items.count == 1)
#expect(pagination.items[0].authService === liveAuth) // first source wins
}
}

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

@ -125,7 +125,7 @@ final class MergedInstanceUITests: ForgejoUITestBase {
}
@MainActor
func testMergedNotificationSwipeDismiss() throws {
func testMergedNotificationSwipeMarkRead() throws {
try setupTwoInstances()
enterMergedMode()
@ -137,19 +137,11 @@ final class MergedInstanceUITests: ForgejoUITestBase {
let firstCell = notificationsList.cells.firstMatch
XCTAssertTrue(firstCell.waitForExistence(timeout: 10), "Should have at least one notification")
// Swipe right to mark as read
// Swipe right to mark as read (the only notification swipe action)
firstCell.swipeRight()
let markReadButton = app.buttons["Mark Read"]
XCTAssertTrue(markReadButton.waitForExistence(timeout: 3), "Mark Read swipe action should appear")
markReadButton.tap()
// Swipe left to dismiss
let nextCell = notificationsList.cells.firstMatch
XCTAssertTrue(nextCell.waitForExistence(timeout: 5), "Should have another notification to dismiss")
nextCell.swipeLeft()
let dismissButton = app.buttons["Dismiss"]
XCTAssertTrue(dismissButton.waitForExistence(timeout: 3), "Dismiss swipe action should appear")
dismissButton.tap()
}
// MARK: - Server URL Helpers

View file

@ -2,7 +2,7 @@ import XCTest
final class NotificationsUITests: ForgejoUITestBase {
// MARK: - Notifications (unread, filter, swipe mark-read, swipe dismiss)
// MARK: - Notifications (unread, filter, swipe mark-read)
@MainActor
func testNotifications() throws {
@ -38,15 +38,68 @@ final class NotificationsUITests: ForgejoUITestBase {
if markReadButton.waitForExistence(timeout: 3) {
markReadButton.tap()
}
}
// Swipe left to dismiss
let nextCell = notificationsList.cells.firstMatch
if nextCell.waitForExistence(timeout: 5) {
nextCell.swipeLeft()
let dismissButton = app.buttons["Dismiss"]
if dismissButton.waitForExistence(timeout: 3) {
dismissButton.tap()
}
}
// MARK: - Open-to-clear (#32)
/// Opening an unread notification marks it read on the server, so it leaves
/// the Unread filter on the next refresh.
///
/// Covers the in-app NotificationsOverviewView path: the tap gesture on the
/// row calls markReadOnOpen, which marks the thread read with
/// removeFromUnread: false so the row stays put during navigation and only
/// drops out of Unread on the next server fetch. The multi-instance and
/// system-notification open paths apply the same fix but aren't reachable
/// from this single-instance harness.
///
/// Sorts after testNotifications and opens whichever unread row is first
/// rather than a fixed one, so it's unaffected by the two notifications that
/// test consumes (the seed leaves several more).
@MainActor
func testOpeningNotificationMarksItRead() throws {
loginAndWaitForHome()
app.tabBars.buttons["Notifications"].tap()
let notificationsList = app.collectionViews.firstMatch
XCTAssertTrue(notificationsList.waitForExistence(timeout: 10))
// Make sure the Unread filter is active.
let unreadButton = app.buttons["Unread"]
XCTAssertTrue(unreadButton.waitForExistence(timeout: 5))
unreadButton.tap()
XCTAssertTrue(notificationsList.waitForExistence(timeout: 10))
// Remember the first unread notification's title (the row's first text)
// so we can assert that specific one leaves the Unread list.
let firstCell = notificationsList.cells.firstMatch
XCTAssertTrue(firstCell.waitForExistence(timeout: 10), "Expected at least one unread notification")
let openedTitle = firstCell.staticTexts.firstMatch.label
XCTAssertFalse(openedTitle.isEmpty, "Could not read the unread notification's title")
// Open it. Navigating into the detail proves the row was tappable; the
// nav bar title changes away from "Notifications" once it's on screen.
firstCell.tap()
let detailNavBar = app.navigationBars.element(boundBy: 0)
XCTAssertTrue(detailNavBar.waitForExistence(timeout: 10), "Detail view did not open")
XCTAssertNotEqual(detailNavBar.identifier, "Notifications", "Did not navigate into the notification detail")
// Back to the list.
app.navigationBars.buttons.element(boundBy: 0).tap()
XCTAssertTrue(notificationsList.waitForExistence(timeout: 10))
// Force a server refresh of Unread by toggling the filter. Opening marks
// the thread read but keeps the row visible until the next fetch, so this
// round-trip is what surfaces the open-to-clear behaviour.
app.buttons["Read"].tap()
XCTAssertTrue(notificationsList.waitForExistence(timeout: 10))
unreadButton.tap()
XCTAssertTrue(notificationsList.waitForExistence(timeout: 10))
// The opened notification should no longer be under Unread.
let openedRow = notificationsList.staticTexts[openedTitle]
XCTAssertFalse(
openedRow.waitForExistence(timeout: 5),
"Opened notification \"\(openedTitle)\" should have left the Unread filter after refresh",
)
}
}

View file

@ -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

@ -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

@ -15,7 +15,7 @@ Forji is a native iOS app for managing your Forgejo repositories, issues, pull r
## App Store
Foji is availbe on the Apple App Store for free: [Forji App Store](https://apps.apple.com/us/app/forji/id6759843639)
Forji is available on the Apple App Store for free: [Forji App Store](https://apps.apple.com/us/app/forji/id6759843639)
## Features

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

@ -1,13 +1,13 @@
{
"originHash" : "3de93078233e3d235402dee817c2fe74a7959fbf4120a66c390f922e560eca49",
"originHash" : "b00b2c6720c5dd1647ef09fa552c1db4a3e6a6124bf0acfbb10670d919e23abb",
"pins" : [
{
"identity" : "forgejokit",
"kind" : "remoteSourceControl",
"location" : "https://codeberg.org/secana/ForgejoKit.git",
"state" : {
"revision" : "7352bc4b1fcfae735ba0008e1e376906c27b9844",
"version" : "0.6.1"
"revision" : "4d1dfc1305c0194fc74b09d15d29940a98cd9752",
"version" : "0.7.0"
}
}
],

View file

@ -8,7 +8,7 @@ let package = Package(
.macOS(.v15),
],
dependencies: [
.package(url: "https://codeberg.org/secana/ForgejoKit.git", from: "0.6.1"),
.package(url: "https://codeberg.org/secana/ForgejoKit.git", from: "0.7.0"),
],
targets: [
.executableTarget(

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,49 @@ 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.
sed -i '' "s/MARKETING_VERSION = [^;]*/MARKETING_VERSION = $NEW/" {{pbxproj}}
sed -i '' "s/CURRENT_PROJECT_VERSION = [^;]*/CURRENT_PROJECT_VERSION = $NEW/" {{pbxproj}}
# 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: