krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.10.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
 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 `&amp;` 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.txt](SCOPE.txt) 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### Release plan
138
139Hutch is an app with a `MARKETING_VERSION`, not a library with an API contract,
140so "breaking change" does not apply. These buckets track *user-visible scale*.
141
142| Version | Contents | Why here |
143| --- | --- | --- |
144| v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all |
145| v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface |
146| 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 |
147| v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); "What's cooking" ingest; doc truth-up; revisit `mailingListSubscribe` | Ships one feature, corrects the map |
148| v3.11.0 | Accessibility | Independent, device-verified |
149| v4.0.0 | Localization *with* translations | The only true re-presentation |
150| — | Swift 6 language mode; cache reads | Internal; ride along, no tag |
151
1523.9.0 was cut this session, bundling the hub.sr.ht writes with the other
153features listed. That reorders the original plan: the "What's cooking" ingest
154and deploy keys — once slated for 3.9.0 — move to 3.10.0, and the hub.sr.ht
155writes that were provisionally 3.10.0 landed early, because the SDL, once
156actually read, turned out to have the mutations (it was not the empty bucket the
157sequencing had guarded against). The ingest still leads 3.10.0: its real output
158is a `SCOPE.txt` that is true.
159
160`KeychainHelper` is deliberately unbucketed; see the SonarCloud hotspots below.
161
162### API features — done (v3.8.0)
163
164- ~~`uploadArtifact` / `deleteArtifact`~~ — artifacts were read-only.
165- ~~`auditLog` (meta.sr.ht)~~ — surfaced under the tokens in Profile.
166- ~~Mailing list creation and settings~~ (`createMailingList`,
167  `updateMailingList`, `deleteMailingList`).
168
169Three of the six planned. The other three did not survive contact:
170
171- `archiveMessage` is `@internal` and inaccessible.
172- The `events` feed was built, then removed: todo.sr.ht's root `events` resolver
173  joins `event.participant_id` against `participant.user_id`, which are
174  different id spaces, so it returns an empty list for everyone. See
175  [SCOPE.txt](SCOPE.txt).
176- Webhook management, `shareSecret`, and build groups are reachable but declined
177  on judgement — see [SCOPE.txt](SCOPE.txt) for the reasoning, so they do not get
178  re-proposed.
179
180### Localization — v4.0.0, and only with translations
181
182The project sets `LOCALIZATION_PREFERS_STRING_CATALOGS = YES` but ships no
183string catalog, so every user-facing string is hardcoded English. Roughly 634
184literals: 239 `Text(`, 150 `Label(`, 117 `Button(`, 77 `Section(`, 51
185`navigationTitle(`.
186
187Worth knowing before starting: a catalog containing only English changes nothing
188for users until translations exist. It is groundwork, and it is the largest diff
189in the roadmap — it touches nearly every view, with the regression risk that
190implies.
191
192That combination is why this is bucketed at v4.0.0 *bundled with at least one
193real translation*, rather than shipped alone. An English-only catalog would earn
194the major number on regression risk while delivering nothing — the wrong trade.
195Hold the catalog until a translation lands. If it ever ships unbundled, it is
196groundwork and belongs in a quiet minor, not a 4.0.
197
198### Accessibility — v3.11.0
199
200Labels and hints appear in 17 of 89 view files. Mechanical and low-risk, but it
201cannot be verified from a build — it needs VoiceOver driven on a device.
202Independent of every other bucket, so it can move if a device pass is convenient.
203
204### SonarCloud backlog — done in code (v3.8.1)
205
206The live count is **53 issues / 10 rules**, not the 51 / 5 an earlier pass
207recorded — a reminder that this section rots like everything else, so query the
208API before budgeting. **0 bugs, 0 vulnerabilities**; everything is a code smell
209or hotspot. What the code side of v3.8.1 actually did:
210
211Fixed (`e93972f`):
212
213- **`swift:S1871`** — `RootView` had byte-identical `.home` / `.recentActivity`
214  deep-link cases. Merged; recent activity is a *section* of Home, not a screen,
215  so both correctly land on the Home tab.
216- **3× `swift:S1186` (empty closure/function, CRITICAL)** — two are
217  `Button("Cancel", role: .cancel) {}` (dialog dismissal needs no body); the
218  third is an empty `URLProtocol.stopLoading()` override in a test. All three now
219  carry a nested comment. Note the earlier claim that "all three are Cancel
220  buttons" was wrong — only two are.
221- **`swift:S108`** — the expected-miss `catch` in `APICacheTests` is commented.
222- **`swift:S1172`** — the unused `url` in `mimeType(for:)` is now `_`.
223- **2× `javascript:S4624`** — the nested template literal in the deep-link
224  builders (`background.js`, `content.js`) is extracted to a `pathSegment` var.
225
226Fixed as a real bug instead (`65412ee`), not silenced:
227
228- **2× `swift:S1172` on `forceRefresh`** — `HomeViewModel.loadProjects` and
229  `loadSystemStatusSnapshot` took the flag and dropped it, so dashboard
230  pull-to-refresh returned cached projects and status. This is the trap named at
231  the top of this file. `ProjectsListView` carried the same defect via its own
232  `.refreshable`. Both fixed at the root in `ProjectService.fetchProjects`.
233
234Won't Fix, with reasons (resolve in SonarCloud's web UI, not in code):
235
236- **35× `swift:S1075` (hardcoded URI)** — 28 in `SourceHutWebDeepLinkMapperTests`,
237  the rest in `HutchDeepLinkURLs`. A deep-link mapper's tests exist to assert
238  literal URLs, and a one-forge client has fixed endpoints. "Fixing" them makes
239  the code worse.
240- **`swift:S107`** — `executeCached` has 8 params across **38 call sites**. A
241  param object would rewrite the hottest networking method for no behaviour or
242  correctness gain against an arbitrary 7-param line. Not worth the regression
243  surface.
244- **`swift:S1481`** — `ArtifactsView`'s `@Bindable var vm` is flagged unused, but
245  `$vm.error` is used at line 134; Sonar's Swift analyzer misses the projected
246  value. False positive — removing it breaks the build.
247- **`javascript:S7785`** — prefers top-level `await` for `injectBannerIfEnabled()`,
248  but `content.js` is a classic content script, not a module. Top-level `await`
249  would be a syntax error. Not applicable.
250- **5× `swift:S1135`** — TODO comments (INFO). The two in `HutchIntents` named
251  real gaps and are now promoted to "App Intent gaps" below, with the inline
252  `TODO`s replaced by plain references — so those two clear. The remaining three
253  (`DeepLink`, `NotificationPreferencesViewModel` ×2) stay until addressed.
254
255The 3 hotspots are the part actually worth thought:
256
257- `KeychainHelper:33` and `:80` (**HIGH**) — the token is stored
258  `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` with no
259  `SecAccessControl`, so it does not require biometric or passcode
260  authentication to read. That is a genuine product decision — should a stolen,
261  unlocked phone hand over a sr.ht token? — not a lint nit. **Unbucketed on
262  purpose:** adding `SecAccessControl` changes what a user must do to read their
263  own token, so it needs a decision first. If the answer is yes, it is a minor
264  bump of its own — a visible auth change should not hide inside a feature
265  release.
266- `ReadmeView:1922` (**LOW**) — unrestricted WebView navigation. Probably a false
267  positive: `isAllowedReadmeNavigationURL` enforces a scheme allowlist. Verify,
268  then annotate.
269
270Query it with:
271`https://sonarcloud.io/api/issues/search?componentKeys=krazywarez_hutch&resolved=false`
272
273This was scoped as a patch on the assumption nothing executes differently — and
274that mostly held: the cosmetic fixes are comments, a merge, and a rename. The one
275exception earns the release its own line: the `forceRefresh` fix changes what
276pull-to-refresh does, so it needs a manual pass on a device before v3.8.1 ships,
277not just a green suite.
278
279### Ingest "What's cooking on SourceHut?" — v3.10.0
280
281sr.ht posts a quarterly update to `~sircmpwn/sr.ht-announce`, mirrored at
282<https://sourcehut.org/blog/>. Nothing in Hutch tracks it, so the API grows and
283this repo's assumptions quietly rot. Read each quarter's post, diff it against
284`Docs/API`, `SCOPE.txt`, and the call sites, and file what changed.
285
286That this is worth doing is already proven: **`SCOPE.txt` claims pronouns are
287"not in GraphQL schema", while `AppState` queries `pronouns` and
288`UserProfileView` displays them.** sr.ht shipped it, the doc never caught up,
289and it has been discouraging work that is in fact already done.
290
291[Q2 2026](https://sourcehut.org/blog/2026-05-28-whats-cooking-q2-2026/) alone
292flags two openings:
293
294- **hub.sr.ht gained a writable GraphQL API** for managing projects and project
295  resources. ~~Rechecked and shipped~~: project create/edit, resource
296  link/unlink, and public discovery landed (#12–#15) — see "hub.sr.ht writes"
297  below. `SCOPE.txt`'s "hub has no public API / no discovery" claim has since
298  been corrected. `mailingListSubscribe` was *not* unblocked — that needs a
299  per-list subscription field lists.sr.ht still lacks.
300- ~~git.sr.ht deploy keys are complete~~ — **shipped** (v3.10.0).
301  `createDeployKey` / `deleteDeployKey` (and `Repository.deployKeys`) are wired
302  into the repository actions menu, owner-only, alongside ACLs.
303
304Start from Q1 2026 forward — that is roughly when the current `Docs/API` dumps
305were captured.
306
307Deploy keys — the one self-contained feature the ingest had already surfaced and
308that the SDL confirmed — shipped in v3.10.0, so this bucket is now the ingest
309itself: research that files what changed rather than building. Everything else it
310turns up gets filed, not built.
311
312### hub.sr.ht writes — projects and discovery done
313
314Reading `api/graph/schema.graphqls` in `hub.sr.ht` settled the Q2 2026 claim:
315the master schema does expose the project write API — `createProject`,
316`updateProject`, `deleteProject`, the `link*` / `unlink*` resource mutations,
317and a public `projects` discovery query. Two of the three items this bucket
318tracked shipped against it:
319
320- ~~Project writes~~ — create (#13), edit (#14), and manage linked
321  repositories, trackers, and mailing lists (#15).
322- ~~Discovery~~ — a browsable directory of public projects (#12).
323
324Built against the master SDL; live deployment on `sr.ht/query` could not be
325confirmed without a token (introspection there is auth-gated), so the mutations
326degrade to a visible error rather than a crash if a field is not yet deployed.
327Verify on a signed-in device.
328
329`mailingListSubscribe` stays out. `MailingList` still has no `subscription`
330field, and public *project* discovery does not help find a mailing list the user
331is *not* subscribed to — the reason Phase 1 declined it. Revisit only if
332lists.sr.ht gains per-list subscription state. `SCOPE.txt` still needs its
333"hub has no public API / no discovery" claim corrected.
334
335### App Intent gaps — unscheduled
336
337Two App Intents in `HutchIntents.swift` are placeholders for features Hutch does
338not have yet. Both are gated on the same missing capability — a global
339search/persistence layer — so neither is schedulable until that lands. (These
340were the two `swift:S1135` TODOs; promoted here so the code carries a reference
341rather than a bare `TODO`.)
342
343- **Global content search.** `SearchHutchIntent` accepts a query — and now a
344  search *type* (#17) — but still routes to the Lookup screen, sourcehut entity
345  resolution, because Hutch has no full-text search across tickets, repos, and
346  lists. When a real search exists, repoint the `.search` route in
347  `SearchHutchIntent.route`. (#17 also completed Check Status / Check Builds
348  dialogs and added the Clear Recent Activity and Unpin Resource mutating
349  intents; those were shipped, not gaps.)
350- **`OpenSavedSearchIntent`.** Saved searches are per-tracker only
351  (`TicketSavedFilterStore`, `ScopedSearchHistoryStore`); there is no global
352  saved-search store for an intent to open. Add the intent once global
353  saved-search persistence exists.
354
355### Swift 6 language mode — no release of its own
356
357The project builds in Swift 5 language mode with
358`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. Moving to Swift 6 is blocked on
359concurrency diagnostics that are warnings today and errors there:
360
361- `APICacheTests` and `BundleUserAgentTests` call main-actor-isolated
362  initialisers and properties from nonisolated contexts, and `await` a few
363  expressions without marking them. Roughly 20 warnings, all in tests.
364- Response types are implicitly `@MainActor` under the default isolation, so
365  their `Decodable` conformances are too. Decoding one from a nonisolated
366  context — an `async let` over a raw `client.execute`, say — warns now and
367  fails then. The pattern that avoids it is `async let` over `@MainActor`
368  methods, as in `HomeViewModel.loadDashboard` and
369  `NotificationPreferencesViewModel.load`.
370
371### Cache reads that bypass the client — no release of its own
372
373`BuildListViewModel`, `RepositoryListViewModel`, and `PasteService` still read
374`client.responseCache` directly, each falling back across two different cache
375keys. That predates `APICacheKeys` and should be folded into `cachedPayload`,
376which already consults the persistent cache before the memory layer.
377
378Like Swift 6 above, this is internal and rides along with whatever release
379already touches that area. Neither justifies a tag.
380
381## Housekeeping
382
383- ~~`Hutch/Hutch/App/AccountSession.swift` sits in a stray nested directory;
384  `Hutch/HutchTests/` is empty.~~ Done (v3.8.1, `9834b78`). Moved beside the rest
385  of `App/`; both stray dirs removed. No pbxproj change — the target is a
386  synchronized root group, so the file compiled by path all along.