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