krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.11.0: ROADMAP.txt · 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.txt](SCOPE.txt) 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- **`MailingList.subscription` does not report your subscription.** The field
31 exists and is typed `MailingListSubscription`, but it returns null even
32 immediately after a `mailingListSubscribe` that hands you back a real
33 subscription id — verified live against `~hutch`, for both owned and
34 non-owned lists. Do not gate subscribe-state on it. The authoritative source
35 is membership in the `subscriptions` query (correct: true after subscribe,
36 false after unsubscribe); the mutations take `listID: Int!`, read from
37 `list(rid:){ id }`. Cost the v3.11.0 subscribe toggle a full afternoon of the
38 "looks right, isn't" variety.
39
40## Phase 0: Unblock CI — done (v3.5.0)
41
42Nothing downstream is trustworthy until the build badge means something.
43
44- ~~Fix `repo-structure-check` in `builds/swift-ci.yml`~~. It asserted
45 `test -d "website"`, but `website/` was removed in `24c8bc6` (2026-04-10), so
46 the check had failed since then.
47- ~~Add a macOS CI job that runs `xcodebuild test`~~. builds.sr.ht has no macOS
48 image and its maintainer has ruled them out, so `xcodebuild` cannot run there.
49 The test plan now runs on the GitHub mirror via `.github/workflows/test.yml`;
50 builds.sr.ht keeps secret scanning and structure checks.
51
52Turning the gate on first required making the suite green. All 214 tests had
53been running only on demand in Xcode, and ten had rotted:
54
55- The `Hutch` scheme referenced `container:HutchTests` without the
56 `.xctestplan` extension, so `xcodebuild test -scheme Hutch` — the path the
57 README sends contributors down — could not run at all.
58- Five were test-side rot: uppercase GraphQL enum rawValues asserted as
59 lowercase, an ordering expectation predating `sortBuildItemsForTriage`,
60 `request.httpBody` read inside a `URLProtocol` (always nil; the body lives on
61 `httpBodyStream`), an incident fixture contradicting its own RSS input, and an
62 image assertion that treated the correct `&` attribute encoding as a bug.
63- Three were real bugs the suite had been right about all along: repository
64 descriptions could not be cleared (a nil subscript assignment drops the key
65 instead of sending JSON null), `serviceNotProvisioned` was unreachable behind
66 a broader `no such` match, and code spans rendered their contents as live
67 markup.
68- One was neither. `keepsDistinctThreadsDistinctByRootMessageID` asserted that
69 two same-subject threads get distinct `id`s, and `eff81f3` obliged by keying
70 `id` on the root Message-ID. The commit message claims this fixed an
71 `Identifiable` collision; it did not, because `deduplicateThreads` merges
72 same-subject threads into one summary before anything renders, so the
73 collision is unreachable. The test constructed summaries by hand and skipped
74 that step. The change is harmless and separating identity from grouping reads
75 better, but the stated reason was wrong.
76
77## Phase 1: Close the write gaps — done (v3.6.0)
78
79Small, independently shippable mutations that already existed in the API but
80were never called. Each removes a "why can't I do this here?" moment.
81
82- ~~`updateTicket`~~ — edit a ticket's subject and body after creation.
83- ~~`deleteTicket`~~ — delete a ticket, behind a confirmation.
84- ~~`ticketSubscribe` / `ticketUnsubscribe`, `trackerSubscribe` /
85 `trackerUnsubscribe`~~ — `Ticket.subscription` and `Tracker.subscription` are
86 null when not subscribed, so both toggles reflect real server state.
87- ~~`mailingListUnsubscribe`~~ — see the caveat below.
88- ~~`updatePreferences`~~ (todo.sr.ht and lists.sr.ht) — `notifySelf` and
89 `copySelf`, surfaced as an Email section in Settings.
90
91`mailingListSubscribe` was left unwired here on the view that per-list state was
92only knowable from the `subscriptions` query, and subscribing needs a list you
93are *not* subscribed to. **Shipped in v3.11.0** once live testing clarified two
94things: a specific list is reachable without a discovery API (Lookup, a project's
95lists, patchsets), and the `subscriptions` query *is* the reliable state source —
96membership in it answers "am I subscribed to this rid?". `MailingList.subscription`
97looked like a shortcut but is a trap (see API traps); it is not used. See
98"mailing list subscribe" below.
99
100### Refactors folded in
101
102- ~~Collapse `SRHTClient`'s duplicated request paths~~. Extracted
103 `makeAuthorizedRequest`, `send`, and `encodedGraphQLBody`; `executeMultipart`
104 became the single-file case of `executeMultipartFiles`. The `#if DEBUG`
105 logging block went from five copies to one. 938 lines to 569.
106- ~~Unify the two `executeCached` overloads~~. The memory-only overload and
107 `executeAndCache` turned out to be dead — all 38 call sites already used the
108 TTL-aware path — so both were removed rather than merged. `responseCache`
109 remains as the in-memory layer behind `cachedPayload`.
110
111Known follow-up: three view models still read `client.responseCache` directly.
112Tracked under Phase 3.
113
114## Phase 2: Patchsets — done (v3.7.0)
115
116The flagship gap. Sending and reviewing patches over email is the SourceHut
117contribution model, and Hutch had no reference to `patchset` anywhere.
118
119Scoped as review-and-triage, not submission:
120
121- ~~Patchset list per mailing list~~ — see the caveat below.
122- ~~Patchset detail~~: cover letter, per-patch diffs (via the existing
123 `DiffView`), checks, and the version / superseded-by chain.
124- ~~Status transitions via `updatePatchset`~~.
125
126Two schema facts shaped the result, and are worth knowing before extending this:
127
128- **`MailingList` has no `patchsets` field.** A list's patchsets cannot be
129 queried directly; they are reachable only through thread roots. The existing
130 threads query now also selects `root.patchset`, so the Patches tab costs no
131 extra request — but it also means patchsets cannot be filtered by status
132 server-side, and only patchsets whose thread appears in the current page are
133 listed.
134- **`Patch` carries no diff.** It has only `index`, `count`, `version`,
135 `prefix`, `subject`, and `trailers`. The diff exists solely inside the email
136 body, so it is recovered with `InboxThreadUtilities.segmentMessageBody` — the
137 same splitter the inbox thread view uses.
138
139Patch *submission* remains out of reach: it is a `git send-email` flow, not a
140GraphQL mutation. Treat that boundary as explicit rather than half-building it.
141
142## Phase 3: Polish and reach
143
144Unlike Phases 1 and 2, this is not one shippable thing. It is several, and they
145are sized very differently — measure before committing to one.
146
147### Release plan
148
149Hutch is an app with a `MARKETING_VERSION`, not a library with an API contract,
150so "breaking change" does not apply. These buckets track *user-visible scale*.
151
152| Version | Contents | Why here |
153| --- | --- | --- |
154| v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all |
155| v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface |
156| v3.9.0 | ~~hub.sr.ht project writes + discovery (#12–#15); multi-language highlighting (#16); App Intents expansion (#17); man-page catalog sync (#7); checklist / recent-activity / pull-to-refresh fixes (#18, #11, #9)~~ | Shipped — the cut this session |
157| v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); ~~"What's cooking" ingest + doc truth-up~~ (done) | Ships one feature, corrects the map |
158| v3.11.0 | ~~Mailing list subscribe/unsubscribe toggle~~ (shipped) | Ingest-surfaced; state via the `subscriptions` query (the `subscription` field is a trap) |
159| v3.12.0 | Accessibility | Independent, device-verified |
160| v4.0.0 | Localization *with* translations | The only true re-presentation |
161| — | Swift 6 language mode; cache reads | Internal; ride along, no tag |
162
1633.9.0 was cut this session, bundling the hub.sr.ht writes with the other
164features listed. That reorders the original plan: the "What's cooking" ingest
165and deploy keys — once slated for 3.9.0 — move to 3.10.0, and the hub.sr.ht
166writes that were provisionally 3.10.0 landed early, because the SDL, once
167actually read, turned out to have the mutations (it was not the empty bucket the
168sequencing had guarded against). The ingest still leads 3.10.0: its real output
169is a `SCOPE.txt` that is true.
170
171`KeychainHelper` is deliberately unbucketed; see the SonarCloud hotspots below.
172
173### API features — done (v3.8.0)
174
175- ~~`uploadArtifact` / `deleteArtifact`~~ — artifacts were read-only.
176- ~~`auditLog` (meta.sr.ht)~~ — surfaced under the tokens in Profile.
177- ~~Mailing list creation and settings~~ (`createMailingList`,
178 `updateMailingList`, `deleteMailingList`).
179
180Three of the six planned. The other three did not survive contact:
181
182- `archiveMessage` is `@internal` and inaccessible.
183- The `events` feed was built, then removed: todo.sr.ht's root `events` resolver
184 joins `event.participant_id` against `participant.user_id`, which are
185 different id spaces, so it returns an empty list for everyone. See
186 [SCOPE.txt](SCOPE.txt).
187- Webhook management, `shareSecret`, and build groups are reachable but declined
188 on judgement — see [SCOPE.txt](SCOPE.txt) for the reasoning, so they do not get
189 re-proposed.
190
191### Localization — v4.0.0, and only with translations
192
193The project sets `LOCALIZATION_PREFERS_STRING_CATALOGS = YES` but ships no
194string catalog, so every user-facing string is hardcoded English. Roughly 634
195literals: 239 `Text(`, 150 `Label(`, 117 `Button(`, 77 `Section(`, 51
196`navigationTitle(`.
197
198Worth knowing before starting: a catalog containing only English changes nothing
199for users until translations exist. It is groundwork, and it is the largest diff
200in the roadmap — it touches nearly every view, with the regression risk that
201implies.
202
203That combination is why this is bucketed at v4.0.0 *bundled with at least one
204real translation*, rather than shipped alone. An English-only catalog would earn
205the major number on regression risk while delivering nothing — the wrong trade.
206Hold the catalog until a translation lands. If it ever ships unbundled, it is
207groundwork and belongs in a quiet minor, not a 4.0.
208
209### Accessibility — v3.11.0
210
211Labels and hints appear in 17 of 89 view files. Mechanical and low-risk, but it
212cannot be verified from a build — it needs VoiceOver driven on a device.
213Independent of every other bucket, so it can move if a device pass is convenient.
214
215### SonarCloud backlog — done in code (v3.8.1)
216
217The live count is **53 issues / 10 rules**, not the 51 / 5 an earlier pass
218recorded — a reminder that this section rots like everything else, so query the
219API before budgeting. **0 bugs, 0 vulnerabilities**; everything is a code smell
220or hotspot. What the code side of v3.8.1 actually did:
221
222Fixed (`e93972f`):
223
224- **`swift:S1871`** — `RootView` had byte-identical `.home` / `.recentActivity`
225 deep-link cases. Merged; recent activity is a *section* of Home, not a screen,
226 so both correctly land on the Home tab.
227- **3× `swift:S1186` (empty closure/function, CRITICAL)** — two are
228 `Button("Cancel", role: .cancel) {}` (dialog dismissal needs no body); the
229 third is an empty `URLProtocol.stopLoading()` override in a test. All three now
230 carry a nested comment. Note the earlier claim that "all three are Cancel
231 buttons" was wrong — only two are.
232- **`swift:S108`** — the expected-miss `catch` in `APICacheTests` is commented.
233- **`swift:S1172`** — the unused `url` in `mimeType(for:)` is now `_`.
234- **2× `javascript:S4624`** — the nested template literal in the deep-link
235 builders (`background.js`, `content.js`) is extracted to a `pathSegment` var.
236
237Fixed as a real bug instead (`65412ee`), not silenced:
238
239- **2× `swift:S1172` on `forceRefresh`** — `HomeViewModel.loadProjects` and
240 `loadSystemStatusSnapshot` took the flag and dropped it, so dashboard
241 pull-to-refresh returned cached projects and status. This is the trap named at
242 the top of this file. `ProjectsListView` carried the same defect via its own
243 `.refreshable`. Both fixed at the root in `ProjectService.fetchProjects`.
244
245Won't Fix, with reasons (resolve in SonarCloud's web UI, not in code):
246
247- **35× `swift:S1075` (hardcoded URI)** — 28 in `SourceHutWebDeepLinkMapperTests`,
248 the rest in `HutchDeepLinkURLs`. A deep-link mapper's tests exist to assert
249 literal URLs, and a one-forge client has fixed endpoints. "Fixing" them makes
250 the code worse.
251- **`swift:S107`** — `executeCached` has 8 params across **38 call sites**. A
252 param object would rewrite the hottest networking method for no behaviour or
253 correctness gain against an arbitrary 7-param line. Not worth the regression
254 surface.
255- **`swift:S1481`** — `ArtifactsView`'s `@Bindable var vm` is flagged unused, but
256 `$vm.error` is used at line 134; Sonar's Swift analyzer misses the projected
257 value. False positive — removing it breaks the build.
258- **`javascript:S7785`** — prefers top-level `await` for `injectBannerIfEnabled()`,
259 but `content.js` is a classic content script, not a module. Top-level `await`
260 would be a syntax error. Not applicable.
261- **5× `swift:S1135`** — TODO comments (INFO). The two in `HutchIntents` named
262 real gaps and are now promoted to "App Intent gaps" below, with the inline
263 `TODO`s replaced by plain references — so those two clear. The remaining three
264 (`DeepLink`, `NotificationPreferencesViewModel` ×2) stay until addressed.
265
266The 3 hotspots are the part actually worth thought:
267
268- `KeychainHelper:33` and `:80` (**HIGH**) — the token is stored
269 `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` with no
270 `SecAccessControl`, so it does not require biometric or passcode
271 authentication to read. That is a genuine product decision — should a stolen,
272 unlocked phone hand over a sr.ht token? — not a lint nit. **Unbucketed on
273 purpose:** adding `SecAccessControl` changes what a user must do to read their
274 own token, so it needs a decision first. If the answer is yes, it is a minor
275 bump of its own — a visible auth change should not hide inside a feature
276 release.
277- `ReadmeView:1922` (**LOW**) — unrestricted WebView navigation. Probably a false
278 positive: `isAllowedReadmeNavigationURL` enforces a scheme allowlist. Verify,
279 then annotate.
280
281Query it with:
282`https://sonarcloud.io/api/issues/search?componentKeys=krazywarez_hutch&resolved=false`
283
284This was scoped as a patch on the assumption nothing executes differently — and
285that mostly held: the cosmetic fixes are comments, a merge, and a rename. The one
286exception earns the release its own line: the `forceRefresh` fix changes what
287pull-to-refresh does, so it needs a manual pass on a device before v3.8.1 ships,
288not just a green suite.
289
290### Ingest "What's cooking on SourceHut?" — v3.10.0
291
292sr.ht posts a quarterly update to `~sircmpwn/sr.ht-announce`, mirrored at
293<https://sourcehut.org/blog/>. Nothing in Hutch tracks it, so the API grows and
294this repo's assumptions quietly rot. Read each quarter's post, diff it against
295`Docs/API`, `SCOPE.txt`, and the call sites, and file what changed.
296
297That this is worth doing is already proven: **`SCOPE.txt` claims pronouns are
298"not in GraphQL schema", while `AppState` queries `pronouns` and
299`UserProfileView` displays them.** sr.ht shipped it, the doc never caught up,
300and it has been discouraging work that is in fact already done.
301
302[Q2 2026](https://sourcehut.org/blog/2026-05-28-whats-cooking-q2-2026/) alone
303flags two openings:
304
305- **hub.sr.ht gained a writable GraphQL API** for managing projects and project
306 resources. ~~Rechecked and shipped~~: project create/edit, resource
307 link/unlink, and public discovery landed (#12–#15) — see "hub.sr.ht writes"
308 below. `SCOPE.txt`'s "hub has no public API / no discovery" claim has since
309 been corrected.
310- ~~git.sr.ht deploy keys are complete~~ — **shipped** (v3.10.0).
311 `createDeployKey` / `deleteDeployKey` (and `Repository.deployKeys`) are wired
312 into the repository actions menu, owner-only, alongside ACLs.
313
314Start from Q1 2026 forward — that is roughly when the current `Docs/API` dumps
315were captured.
316
317Deploy keys — the one self-contained feature the ingest had already surfaced and
318that the SDL confirmed — shipped in v3.10.0.
319
320**Ingest run (2026-08, Q1–Q2 posts + live schema introspection with a test
321token):** everything else the posts flagged is already in Hutch — RIDs (used
322throughout), pronouns and avatars (queried and displayed, avatar upload/delete
323in Settings), hub project writes and discovery (shipped), deploy keys (shipped).
324Planned-but-not-yet-shipped upstream, so nothing to build: anonymous API access
325and "standardized / connections-spec" GraphQL (Q2 named both as future work).
326The one *new* opening the introspection turned up is below.
327
328### Mailing list subscribe — done (v3.11.0)
329
330A subscribe / unsubscribe toggle now sits in the mailing-list detail toolbar
331(`MailingListDetailView`, which backs both Lookup results and
332`ProjectMailingListView`). It is hidden for lists you own and while state is
333unknown.
334
335Live testing rewrote the plan. `MailingList.subscription` looked like the state
336source but is a trap — it returns null even right after a successful
337`mailingListSubscribe` that hands back a subscription id (see API traps). So
338state comes from membership in the `subscriptions` query, which *is* reliable,
339and the numeric `listID` the mutations require comes from `list(rid:){ id }`.
340The mutations themselves (`mailingListSubscribe` / `mailingListUnsubscribe`,
341`listID: Int!`) work as expected. Phase 1's "no discovery API" worry was moot:
342a specific list is reachable via Lookup, a project's lists, or patchsets, and
343that is all subscribing needs.
344
345### hub.sr.ht writes — projects and discovery done
346
347Reading `api/graph/schema.graphqls` in `hub.sr.ht` settled the Q2 2026 claim:
348the master schema does expose the project write API — `createProject`,
349`updateProject`, `deleteProject`, the `link*` / `unlink*` resource mutations,
350and a public `projects` discovery query. Two of the three items this bucket
351tracked shipped against it:
352
353- ~~Project writes~~ — create (#13), edit (#14), and manage linked
354 repositories, trackers, and mailing lists (#15).
355- ~~Discovery~~ — a browsable directory of public projects (#12).
356
357Built against the master SDL; live deployment on `sr.ht/query` could not be
358confirmed without a token (introspection there is auth-gated), so the mutations
359degrade to a visible error rather than a crash if a field is not yet deployed.
360Verify on a signed-in device.
361
362`mailingListSubscribe` is now buildable: the ingest found `MailingList` gained a
363`subscription` field, so per-list state is readable and the subscribe/unsubscribe
364toggle can reflect it — see the "mailing list subscribe" bucket below. `SCOPE.txt`
365has since had its "hub has no public API / no discovery" claim corrected.
366
367### App Intent gaps — unscheduled
368
369Two App Intents in `HutchIntents.swift` are placeholders for features Hutch does
370not have yet. Both are gated on the same missing capability — a global
371search/persistence layer — so neither is schedulable until that lands. (These
372were the two `swift:S1135` TODOs; promoted here so the code carries a reference
373rather than a bare `TODO`.)
374
375- **Global content search.** `SearchHutchIntent` accepts a query — and now a
376 search *type* (#17) — but still routes to the Lookup screen, sourcehut entity
377 resolution, because Hutch has no full-text search across tickets, repos, and
378 lists. When a real search exists, repoint the `.search` route in
379 `SearchHutchIntent.route`. (#17 also completed Check Status / Check Builds
380 dialogs and added the Clear Recent Activity and Unpin Resource mutating
381 intents; those were shipped, not gaps.)
382- **`OpenSavedSearchIntent`.** Saved searches are per-tracker only
383 (`TicketSavedFilterStore`, `ScopedSearchHistoryStore`); there is no global
384 saved-search store for an intent to open. Add the intent once global
385 saved-search persistence exists.
386
387### Swift 6 language mode — no release of its own
388
389The project builds in Swift 5 language mode with
390`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. Moving to Swift 6 is blocked on
391concurrency diagnostics that are warnings today and errors there:
392
393- `APICacheTests` and `BundleUserAgentTests` call main-actor-isolated
394 initialisers and properties from nonisolated contexts, and `await` a few
395 expressions without marking them. Roughly 20 warnings, all in tests.
396- Response types are implicitly `@MainActor` under the default isolation, so
397 their `Decodable` conformances are too. Decoding one from a nonisolated
398 context — an `async let` over a raw `client.execute`, say — warns now and
399 fails then. The pattern that avoids it is `async let` over `@MainActor`
400 methods, as in `HomeViewModel.loadDashboard` and
401 `NotificationPreferencesViewModel.load`.
402
403### Cache reads that bypass the client — no release of its own
404
405`BuildListViewModel`, `RepositoryListViewModel`, and `PasteService` still read
406`client.responseCache` directly, each falling back across two different cache
407keys. That predates `APICacheKeys` and should be folded into `cachedPayload`,
408which already consults the persistent cache before the memory layer.
409
410Like Swift 6 above, this is internal and rides along with whatever release
411already touches that area. Neither justifies a tag.
412
413## Housekeeping
414
415- ~~`Hutch/Hutch/App/AccountSession.swift` sits in a stray nested directory;
416 `Hutch/HutchTests/` is empty.~~ Done (v3.8.1, `9834b78`). Moved beside the rest
417 of `App/`; both stray dirs removed. No pbxproj change — the target is a
418 synchronized root group, so the file compiled by path all along.