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 `&amp;` 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.