krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.8.0: ROADMAP.md · raw
1# Roadmap
2
3Planned work for Hutch, ordered by dependency. Feature gaps below were
4identified by diffing the GraphQL schema dumps in `Docs/API` against actual
5call sites in the Swift source.
6
7See [SCOPE.md](SCOPE.md) for features that are intentionally out of scope.
8
9## SourceHut API traps
10
11Things the schema does not tell you, each of which has already cost real time.
12
13- **`Thread.updated` is not the thread's activity.** It is the root email's
14 insert time and never advances when a reply arrives, despite the name and
15 despite the schema describing `MailingList.threads` as ordered "most recently
16 bumped". sr.ht returns `updated` seven seconds after `root.date` on a thread
17 carrying four replies. Anything built on it silently treats thread creation as
18 activity. Use `MailingList.emails`, which is reverse-chronological arrival
19 data — see `MailingListActivity`. Prefer `Email.received` over `Email.date`:
20 `received` is server-side and non-null, `date` comes from the sender's header
21 and is neither.
22- **The schema dumps in `Docs/API` are partial.** They were captured with an
23 introspection query that omits `inputFields` and `enumValues`, so they cannot
24 answer what a mutation's input looks like or what an enum accepts — both come
25 back as empty arrays rather than as an error. For input shapes and enum cases,
26 read the real SDL instead:
27 `git clone --depth 1 https://git.sr.ht/~sircmpwn/<service>.sr.ht` and look at
28 `api/graph/schema.graphqls`. Regenerating the dumps with a full introspection
29 query would remove the trap.
30
31## Phase 0: Unblock CI — done (v3.5.0)
32
33Nothing downstream is trustworthy until the build badge means something.
34
35- ~~Fix `repo-structure-check` in `builds/swift-ci.yml`~~. It asserted
36 `test -d "website"`, but `website/` was removed in `24c8bc6` (2026-04-10), so
37 the check had failed since then.
38- ~~Add a macOS CI job that runs `xcodebuild test`~~. builds.sr.ht has no macOS
39 image and its maintainer has ruled them out, so `xcodebuild` cannot run there.
40 The test plan now runs on the GitHub mirror via `.github/workflows/test.yml`;
41 builds.sr.ht keeps secret scanning and structure checks.
42
43Turning the gate on first required making the suite green. All 214 tests had
44been running only on demand in Xcode, and ten had rotted:
45
46- The `Hutch` scheme referenced `container:HutchTests` without the
47 `.xctestplan` extension, so `xcodebuild test -scheme Hutch` — the path the
48 README sends contributors down — could not run at all.
49- Five were test-side rot: uppercase GraphQL enum rawValues asserted as
50 lowercase, an ordering expectation predating `sortBuildItemsForTriage`,
51 `request.httpBody` read inside a `URLProtocol` (always nil; the body lives on
52 `httpBodyStream`), an incident fixture contradicting its own RSS input, and an
53 image assertion that treated the correct `&` attribute encoding as a bug.
54- Three were real bugs the suite had been right about all along: repository
55 descriptions could not be cleared (a nil subscript assignment drops the key
56 instead of sending JSON null), `serviceNotProvisioned` was unreachable behind
57 a broader `no such` match, and code spans rendered their contents as live
58 markup.
59- One was neither. `keepsDistinctThreadsDistinctByRootMessageID` asserted that
60 two same-subject threads get distinct `id`s, and `eff81f3` obliged by keying
61 `id` on the root Message-ID. The commit message claims this fixed an
62 `Identifiable` collision; it did not, because `deduplicateThreads` merges
63 same-subject threads into one summary before anything renders, so the
64 collision is unreachable. The test constructed summaries by hand and skipped
65 that step. The change is harmless and separating identity from grouping reads
66 better, but the stated reason was wrong.
67
68## Phase 1: Close the write gaps — done (v3.6.0)
69
70Small, independently shippable mutations that already existed in the API but
71were never called. Each removes a "why can't I do this here?" moment.
72
73- ~~`updateTicket`~~ — edit a ticket's subject and body after creation.
74- ~~`deleteTicket`~~ — delete a ticket, behind a confirmation.
75- ~~`ticketSubscribe` / `ticketUnsubscribe`, `trackerSubscribe` /
76 `trackerUnsubscribe`~~ — `Ticket.subscription` and `Tracker.subscription` are
77 null when not subscribed, so both toggles reflect real server state.
78- ~~`mailingListUnsubscribe`~~ — see the caveat below.
79- ~~`updatePreferences`~~ (todo.sr.ht and lists.sr.ht) — `notifySelf` and
80 `copySelf`, surfaced as an Email section in Settings.
81
82`mailingListSubscribe` is deliberately not wired up. `MailingList` has no
83`subscription` field, unlike `Ticket` and `Tracker`, so per-list state is only
84knowable from the `subscriptions` query — which by definition lists what the
85user is already subscribed to. Subscribing needs a list the user is *not*
86subscribed to, and sr.ht exposes no discovery API to find one (see
87[SCOPE.md](SCOPE.md) on hub.sr.ht). Revisit if hub.sr.ht ever gains an API, or
88alongside Phase 2, which surfaces lists through patchsets.
89
90### Refactors folded in
91
92- ~~Collapse `SRHTClient`'s duplicated request paths~~. Extracted
93 `makeAuthorizedRequest`, `send`, and `encodedGraphQLBody`; `executeMultipart`
94 became the single-file case of `executeMultipartFiles`. The `#if DEBUG`
95 logging block went from five copies to one. 938 lines to 569.
96- ~~Unify the two `executeCached` overloads~~. The memory-only overload and
97 `executeAndCache` turned out to be dead — all 38 call sites already used the
98 TTL-aware path — so both were removed rather than merged. `responseCache`
99 remains as the in-memory layer behind `cachedPayload`.
100
101Known follow-up: three view models still read `client.responseCache` directly.
102Tracked under Phase 3.
103
104## Phase 2: Patchsets — done (v3.7.0)
105
106The flagship gap. Sending and reviewing patches over email is the SourceHut
107contribution model, and Hutch had no reference to `patchset` anywhere.
108
109Scoped as review-and-triage, not submission:
110
111- ~~Patchset list per mailing list~~ — see the caveat below.
112- ~~Patchset detail~~: cover letter, per-patch diffs (via the existing
113 `DiffView`), checks, and the version / superseded-by chain.
114- ~~Status transitions via `updatePatchset`~~.
115
116Two schema facts shaped the result, and are worth knowing before extending this:
117
118- **`MailingList` has no `patchsets` field.** A list's patchsets cannot be
119 queried directly; they are reachable only through thread roots. The existing
120 threads query now also selects `root.patchset`, so the Patches tab costs no
121 extra request — but it also means patchsets cannot be filtered by status
122 server-side, and only patchsets whose thread appears in the current page are
123 listed.
124- **`Patch` carries no diff.** It has only `index`, `count`, `version`,
125 `prefix`, `subject`, and `trailers`. The diff exists solely inside the email
126 body, so it is recovered with `InboxThreadUtilities.segmentMessageBody` — the
127 same splitter the inbox thread view uses.
128
129Patch *submission* remains out of reach: it is a `git send-email` flow, not a
130GraphQL mutation. Treat that boundary as explicit rather than half-building it.
131
132## Phase 3: Polish and reach
133
134Unlike Phases 1 and 2, this is not one shippable thing. It is several, and they
135are sized very differently — measure before committing to one.
136
137### API features — done (v3.8.0)
138
139- ~~`uploadArtifact` / `deleteArtifact`~~ — artifacts were read-only.
140- ~~`auditLog` (meta.sr.ht)~~ — surfaced under the tokens in Profile.
141- ~~Mailing list creation and settings~~ (`createMailingList`,
142 `updateMailingList`, `deleteMailingList`).
143
144Three of the six planned. The other three did not survive contact:
145
146- `archiveMessage` is `@internal` and inaccessible.
147- The `events` feed was built, then removed: todo.sr.ht's root `events` resolver
148 joins `event.participant_id` against `participant.user_id`, which are
149 different id spaces, so it returns an empty list for everyone. See
150 [SCOPE.md](SCOPE.md).
151- Webhook management, `shareSecret`, and build groups are reachable but declined
152 on judgement — see [SCOPE.md](SCOPE.md) for the reasoning, so they do not get
153 re-proposed.
154
155### Localization
156
157The project sets `LOCALIZATION_PREFERS_STRING_CATALOGS = YES` but ships no
158string catalog, so every user-facing string is hardcoded English. Roughly 634
159literals: 239 `Text(`, 150 `Label(`, 117 `Button(`, 77 `Section(`, 51
160`navigationTitle(`.
161
162Worth knowing before starting: a catalog containing only English changes nothing
163for users until translations exist. It is groundwork, and it is the largest diff
164in the roadmap — it touches nearly every view, with the regression risk that
165implies.
166
167### Accessibility
168
169Labels and hints appear in 17 of 89 view files. Mechanical and low-risk, but it
170cannot be verified from a build — it needs VoiceOver driven on a device.
171
172### Swift 6 language mode
173
174The project builds in Swift 5 language mode with
175`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. Moving to Swift 6 is blocked on
176concurrency diagnostics that are warnings today and errors there:
177
178- `APICacheTests` and `BundleUserAgentTests` call main-actor-isolated
179 initialisers and properties from nonisolated contexts, and `await` a few
180 expressions without marking them. Roughly 20 warnings, all in tests.
181- Response types are implicitly `@MainActor` under the default isolation, so
182 their `Decodable` conformances are too. Decoding one from a nonisolated
183 context — an `async let` over a raw `client.execute`, say — warns now and
184 fails then. The pattern that avoids it is `async let` over `@MainActor`
185 methods, as in `HomeViewModel.loadDashboard` and
186 `NotificationPreferencesViewModel.load`.
187
188### Cache reads that bypass the client
189
190`BuildListViewModel`, `RepositoryListViewModel`, and `PasteService` still read
191`client.responseCache` directly, each falling back across two different cache
192keys. That predates `APICacheKeys` and should be folded into `cachedPayload`,
193which already consults the persistent cache before the memory layer.
194
195## Housekeeping
196
197- `Hutch/Hutch/App/AccountSession.swift` sits in a stray nested directory;
198 `Hutch/HutchTests/` is empty.