From fbcb4113f110a7db9cf21d18510cb59c2e90ba23 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sun, 2 Aug 2026 15:29:33 -0500 Subject: convert readme to nfo; convert docs to txt; relicense to 0bsd --- ROADMAP.txt | 370 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 ROADMAP.txt (limited to 'ROADMAP.txt') diff --git a/ROADMAP.txt b/ROADMAP.txt new file mode 100644 index 0000000..cf608b5 --- /dev/null +++ b/ROADMAP.txt @@ -0,0 +1,370 @@ +# Roadmap + +Planned work for Hutch, ordered by dependency. Feature gaps below were +identified by diffing the GraphQL schema dumps in `Docs/API` against actual +call sites in the Swift source. + +See [SCOPE.txt](SCOPE.txt) for features that are intentionally out of scope. + +## SourceHut API traps + +Things the schema does not tell you, each of which has already cost real time. + +- **`Thread.updated` is not the thread's activity.** It is the root email's + insert time and never advances when a reply arrives, despite the name and + despite the schema describing `MailingList.threads` as ordered "most recently + bumped". sr.ht returns `updated` seven seconds after `root.date` on a thread + carrying four replies. Anything built on it silently treats thread creation as + activity. Use `MailingList.emails`, which is reverse-chronological arrival + data — see `MailingListActivity`. Prefer `Email.received` over `Email.date`: + `received` is server-side and non-null, `date` comes from the sender's header + and is neither. +- **The schema dumps in `Docs/API` are partial.** They were captured with an + introspection query that omits `inputFields` and `enumValues`, so they cannot + answer what a mutation's input looks like or what an enum accepts — both come + back as empty arrays rather than as an error. For input shapes and enum cases, + read the real SDL instead: + `git clone --depth 1 https://git.sr.ht/~sircmpwn/.sr.ht` and look at + `api/graph/schema.graphqls`. Regenerating the dumps with a full introspection + query would remove the trap. + +## Phase 0: Unblock CI — done (v3.5.0) + +Nothing downstream is trustworthy until the build badge means something. + +- ~~Fix `repo-structure-check` in `builds/swift-ci.yml`~~. It asserted + `test -d "website"`, but `website/` was removed in `24c8bc6` (2026-04-10), so + the check had failed since then. +- ~~Add a macOS CI job that runs `xcodebuild test`~~. builds.sr.ht has no macOS + image and its maintainer has ruled them out, so `xcodebuild` cannot run there. + The test plan now runs on the GitHub mirror via `.github/workflows/test.yml`; + builds.sr.ht keeps secret scanning and structure checks. + +Turning the gate on first required making the suite green. All 214 tests had +been running only on demand in Xcode, and ten had rotted: + +- The `Hutch` scheme referenced `container:HutchTests` without the + `.xctestplan` extension, so `xcodebuild test -scheme Hutch` — the path the + README sends contributors down — could not run at all. +- Five were test-side rot: uppercase GraphQL enum rawValues asserted as + lowercase, an ordering expectation predating `sortBuildItemsForTriage`, + `request.httpBody` read inside a `URLProtocol` (always nil; the body lives on + `httpBodyStream`), an incident fixture contradicting its own RSS input, and an + image assertion that treated the correct `&` attribute encoding as a bug. +- Three were real bugs the suite had been right about all along: repository + descriptions could not be cleared (a nil subscript assignment drops the key + instead of sending JSON null), `serviceNotProvisioned` was unreachable behind + a broader `no such` match, and code spans rendered their contents as live + markup. +- One was neither. `keepsDistinctThreadsDistinctByRootMessageID` asserted that + two same-subject threads get distinct `id`s, and `eff81f3` obliged by keying + `id` on the root Message-ID. The commit message claims this fixed an + `Identifiable` collision; it did not, because `deduplicateThreads` merges + same-subject threads into one summary before anything renders, so the + collision is unreachable. The test constructed summaries by hand and skipped + that step. The change is harmless and separating identity from grouping reads + better, but the stated reason was wrong. + +## Phase 1: Close the write gaps — done (v3.6.0) + +Small, independently shippable mutations that already existed in the API but +were never called. Each removes a "why can't I do this here?" moment. + +- ~~`updateTicket`~~ — edit a ticket's subject and body after creation. +- ~~`deleteTicket`~~ — delete a ticket, behind a confirmation. +- ~~`ticketSubscribe` / `ticketUnsubscribe`, `trackerSubscribe` / + `trackerUnsubscribe`~~ — `Ticket.subscription` and `Tracker.subscription` are + null when not subscribed, so both toggles reflect real server state. +- ~~`mailingListUnsubscribe`~~ — see the caveat below. +- ~~`updatePreferences`~~ (todo.sr.ht and lists.sr.ht) — `notifySelf` and + `copySelf`, surfaced as an Email section in Settings. + +`mailingListSubscribe` is deliberately not wired up. `MailingList` has no +`subscription` field, unlike `Ticket` and `Tracker`, so per-list state is only +knowable from the `subscriptions` query — which by definition lists what the +user is already subscribed to. Subscribing needs a list the user is *not* +subscribed to, and sr.ht exposes no discovery API to find one (see +[SCOPE.txt](SCOPE.txt) on hub.sr.ht). Revisit if hub.sr.ht ever gains an API, or +alongside Phase 2, which surfaces lists through patchsets. + +### Refactors folded in + +- ~~Collapse `SRHTClient`'s duplicated request paths~~. Extracted + `makeAuthorizedRequest`, `send`, and `encodedGraphQLBody`; `executeMultipart` + became the single-file case of `executeMultipartFiles`. The `#if DEBUG` + logging block went from five copies to one. 938 lines to 569. +- ~~Unify the two `executeCached` overloads~~. The memory-only overload and + `executeAndCache` turned out to be dead — all 38 call sites already used the + TTL-aware path — so both were removed rather than merged. `responseCache` + remains as the in-memory layer behind `cachedPayload`. + +Known follow-up: three view models still read `client.responseCache` directly. +Tracked under Phase 3. + +## Phase 2: Patchsets — done (v3.7.0) + +The flagship gap. Sending and reviewing patches over email is the SourceHut +contribution model, and Hutch had no reference to `patchset` anywhere. + +Scoped as review-and-triage, not submission: + +- ~~Patchset list per mailing list~~ — see the caveat below. +- ~~Patchset detail~~: cover letter, per-patch diffs (via the existing + `DiffView`), checks, and the version / superseded-by chain. +- ~~Status transitions via `updatePatchset`~~. + +Two schema facts shaped the result, and are worth knowing before extending this: + +- **`MailingList` has no `patchsets` field.** A list's patchsets cannot be + queried directly; they are reachable only through thread roots. The existing + threads query now also selects `root.patchset`, so the Patches tab costs no + extra request — but it also means patchsets cannot be filtered by status + server-side, and only patchsets whose thread appears in the current page are + listed. +- **`Patch` carries no diff.** It has only `index`, `count`, `version`, + `prefix`, `subject`, and `trailers`. The diff exists solely inside the email + body, so it is recovered with `InboxThreadUtilities.segmentMessageBody` — the + same splitter the inbox thread view uses. + +Patch *submission* remains out of reach: it is a `git send-email` flow, not a +GraphQL mutation. Treat that boundary as explicit rather than half-building it. + +## Phase 3: Polish and reach + +Unlike Phases 1 and 2, this is not one shippable thing. It is several, and they +are sized very differently — measure before committing to one. + +### Release plan + +Hutch is an app with a `MARKETING_VERSION`, not a library with an API contract, +so "breaking change" does not apply. These buckets track *user-visible scale*. + +| Version | Contents | Why here | +| --- | --- | --- | +| v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all | +| v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface | +| v3.9.0 | "What's cooking" ingest; doc truth-up; deploy keys | Ships one feature, corrects the map | +| v3.10.0 | hub.sr.ht writes: projects, discovery, `mailingListSubscribe` | Provisional — gated on what v3.9.0 finds | +| v3.11.0 | Accessibility | Independent, device-verified | +| v4.0.0 | Localization *with* translations | The only true re-presentation | +| — | Swift 6 language mode; cache reads | Internal; ride along, no tag | + +Ordering is by dependency, not size. v3.9.0 leads because it is the only item +that corrects the others' inputs: the ingest's real output is a `SCOPE.txt` that +is true, and v3.10.0 rests entirely on one unverified sentence in a blog post. +Do not commit v3.10.0's number until the SDL has been read — the bucket may turn +out to be empty, which is the point of sequencing it second. + +`KeychainHelper` is deliberately unbucketed; see the SonarCloud hotspots below. + +### API features — done (v3.8.0) + +- ~~`uploadArtifact` / `deleteArtifact`~~ — artifacts were read-only. +- ~~`auditLog` (meta.sr.ht)~~ — surfaced under the tokens in Profile. +- ~~Mailing list creation and settings~~ (`createMailingList`, + `updateMailingList`, `deleteMailingList`). + +Three of the six planned. The other three did not survive contact: + +- `archiveMessage` is `@internal` and inaccessible. +- The `events` feed was built, then removed: todo.sr.ht's root `events` resolver + joins `event.participant_id` against `participant.user_id`, which are + different id spaces, so it returns an empty list for everyone. See + [SCOPE.txt](SCOPE.txt). +- Webhook management, `shareSecret`, and build groups are reachable but declined + on judgement — see [SCOPE.txt](SCOPE.txt) for the reasoning, so they do not get + re-proposed. + +### Localization — v4.0.0, and only with translations + +The project sets `LOCALIZATION_PREFERS_STRING_CATALOGS = YES` but ships no +string catalog, so every user-facing string is hardcoded English. Roughly 634 +literals: 239 `Text(`, 150 `Label(`, 117 `Button(`, 77 `Section(`, 51 +`navigationTitle(`. + +Worth knowing before starting: a catalog containing only English changes nothing +for users until translations exist. It is groundwork, and it is the largest diff +in the roadmap — it touches nearly every view, with the regression risk that +implies. + +That combination is why this is bucketed at v4.0.0 *bundled with at least one +real translation*, rather than shipped alone. An English-only catalog would earn +the major number on regression risk while delivering nothing — the wrong trade. +Hold the catalog until a translation lands. If it ever ships unbundled, it is +groundwork and belongs in a quiet minor, not a 4.0. + +### Accessibility — v3.11.0 + +Labels and hints appear in 17 of 89 view files. Mechanical and low-risk, but it +cannot be verified from a build — it needs VoiceOver driven on a device. +Independent of every other bucket, so it can move if a device pass is convenient. + +### SonarCloud backlog — done in code (v3.8.1) + +The live count is **53 issues / 10 rules**, not the 51 / 5 an earlier pass +recorded — a reminder that this section rots like everything else, so query the +API before budgeting. **0 bugs, 0 vulnerabilities**; everything is a code smell +or hotspot. What the code side of v3.8.1 actually did: + +Fixed (`e93972f`): + +- **`swift:S1871`** — `RootView` had byte-identical `.home` / `.recentActivity` + deep-link cases. Merged; recent activity is a *section* of Home, not a screen, + so both correctly land on the Home tab. +- **3× `swift:S1186` (empty closure/function, CRITICAL)** — two are + `Button("Cancel", role: .cancel) {}` (dialog dismissal needs no body); the + third is an empty `URLProtocol.stopLoading()` override in a test. All three now + carry a nested comment. Note the earlier claim that "all three are Cancel + buttons" was wrong — only two are. +- **`swift:S108`** — the expected-miss `catch` in `APICacheTests` is commented. +- **`swift:S1172`** — the unused `url` in `mimeType(for:)` is now `_`. +- **2× `javascript:S4624`** — the nested template literal in the deep-link + builders (`background.js`, `content.js`) is extracted to a `pathSegment` var. + +Fixed as a real bug instead (`65412ee`), not silenced: + +- **2× `swift:S1172` on `forceRefresh`** — `HomeViewModel.loadProjects` and + `loadSystemStatusSnapshot` took the flag and dropped it, so dashboard + pull-to-refresh returned cached projects and status. This is the trap named at + the top of this file. `ProjectsListView` carried the same defect via its own + `.refreshable`. Both fixed at the root in `ProjectService.fetchProjects`. + +Won't Fix, with reasons (resolve in SonarCloud's web UI, not in code): + +- **35× `swift:S1075` (hardcoded URI)** — 28 in `SourceHutWebDeepLinkMapperTests`, + the rest in `HutchDeepLinkURLs`. A deep-link mapper's tests exist to assert + literal URLs, and a one-forge client has fixed endpoints. "Fixing" them makes + the code worse. +- **`swift:S107`** — `executeCached` has 8 params across **38 call sites**. A + param object would rewrite the hottest networking method for no behaviour or + correctness gain against an arbitrary 7-param line. Not worth the regression + surface. +- **`swift:S1481`** — `ArtifactsView`'s `@Bindable var vm` is flagged unused, but + `$vm.error` is used at line 134; Sonar's Swift analyzer misses the projected + value. False positive — removing it breaks the build. +- **`javascript:S7785`** — prefers top-level `await` for `injectBannerIfEnabled()`, + but `content.js` is a classic content script, not a module. Top-level `await` + would be a syntax error. Not applicable. +- **5× `swift:S1135`** — TODO comments (INFO). The two in `HutchIntents` named + real gaps and are now promoted to "App Intent gaps" below, with the inline + `TODO`s replaced by plain references — so those two clear. The remaining three + (`DeepLink`, `NotificationPreferencesViewModel` ×2) stay until addressed. + +The 3 hotspots are the part actually worth thought: + +- `KeychainHelper:33` and `:80` (**HIGH**) — the token is stored + `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` with no + `SecAccessControl`, so it does not require biometric or passcode + authentication to read. That is a genuine product decision — should a stolen, + unlocked phone hand over a sr.ht token? — not a lint nit. **Unbucketed on + purpose:** adding `SecAccessControl` changes what a user must do to read their + own token, so it needs a decision first. If the answer is yes, it is a minor + bump of its own — a visible auth change should not hide inside a feature + release. +- `ReadmeView:1922` (**LOW**) — unrestricted WebView navigation. Probably a false + positive: `isAllowedReadmeNavigationURL` enforces a scheme allowlist. Verify, + then annotate. + +Query it with: +`https://sonarcloud.io/api/issues/search?componentKeys=krazywarez_hutch&resolved=false` + +This was scoped as a patch on the assumption nothing executes differently — and +that mostly held: the cosmetic fixes are comments, a merge, and a rename. The one +exception earns the release its own line: the `forceRefresh` fix changes what +pull-to-refresh does, so it needs a manual pass on a device before v3.8.1 ships, +not just a green suite. + +### Ingest "What's cooking on SourceHut?" — v3.9.0 + +sr.ht posts a quarterly update to `~sircmpwn/sr.ht-announce`, mirrored at +. Nothing in Hutch tracks it, so the API grows and +this repo's assumptions quietly rot. Read each quarter's post, diff it against +`Docs/API`, `SCOPE.txt`, and the call sites, and file what changed. + +That this is worth doing is already proven: **`SCOPE.txt` claims pronouns are +"not in GraphQL schema", while `AppState` queries `pronouns` and +`UserProfileView` displays them.** sr.ht shipped it, the doc never caught up, +and it has been discouraging work that is in fact already done. + +[Q2 2026](https://sourcehut.org/blog/2026-05-28-whats-cooking-q2-2026/) alone +flags two openings: + +- **hub.sr.ht gained a writable GraphQL API** for managing projects and project + resources. Hutch's projects are read-only, and `SCOPE.txt` still rules out + discovery on the grounds that hub has no public API. Both claims need + rechecking — this may also unblock `mailingListSubscribe`, which Phase 1 left + out for exactly that reason. +- **git.sr.ht deploy keys are complete** (`createDeployKey` / `deleteDeployKey` + are in the SDL). Hutch never calls them. + +Start from Q1 2026 forward — that is roughly when the current `Docs/API` dumps +were captured. + +Research does not ship, so v3.9.0 pairs the ingest with **deploy keys** — the one +self-contained feature it has already surfaced and that the SDL confirms exists. +That gives the release something a user can see. Everything else the ingest turns +up gets filed, not built, and hub.sr.ht gets its own bucket below. + +### hub.sr.ht writes — v3.10.0, provisional + +Everything here rests on a single sentence in the Q2 2026 post: that hub.sr.ht +gained a writable GraphQL API. If true, three things unblock at once — +project writes (Hutch's projects are read-only), discovery (which `SCOPE.txt` +rules out on the grounds hub has no public API), and `mailingListSubscribe`, +which Phase 1 declined for exactly that reason. + +All three live or die on the same unverified claim, which is why this is +sequenced after the ingest rather than planned now. Read +`api/graph/schema.graphqls` in `hub.sr.ht` before committing the version number. +The bucket may be empty. + +### App Intent gaps — unscheduled + +Two App Intents in `HutchIntents.swift` are placeholders for features Hutch does +not have yet. Both are gated on the same missing capability — a global +search/persistence layer — so neither is schedulable until that lands. (These +were the two `swift:S1135` TODOs; promoted here so the code carries a reference +rather than a bare `TODO`.) + +- **Global content search.** `SearchHutchIntent` accepts a query but routes to + the Lookup screen — sourcehut entity resolution — because Hutch has no + full-text search across tickets, repos, and lists. Its own description says + "Opens Hutch lookup with a search query." When a real search exists, repoint + the `.search` route in `SearchHutchIntent.route`. +- **`OpenSavedSearchIntent`.** Saved searches are per-tracker only + (`TicketSavedFilterStore`, `ScopedSearchHistoryStore`); there is no global + saved-search store for an intent to open. Add the intent once global + saved-search persistence exists. + +### Swift 6 language mode — no release of its own + +The project builds in Swift 5 language mode with +`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. Moving to Swift 6 is blocked on +concurrency diagnostics that are warnings today and errors there: + +- `APICacheTests` and `BundleUserAgentTests` call main-actor-isolated + initialisers and properties from nonisolated contexts, and `await` a few + expressions without marking them. Roughly 20 warnings, all in tests. +- Response types are implicitly `@MainActor` under the default isolation, so + their `Decodable` conformances are too. Decoding one from a nonisolated + context — an `async let` over a raw `client.execute`, say — warns now and + fails then. The pattern that avoids it is `async let` over `@MainActor` + methods, as in `HomeViewModel.loadDashboard` and + `NotificationPreferencesViewModel.load`. + +### Cache reads that bypass the client — no release of its own + +`BuildListViewModel`, `RepositoryListViewModel`, and `PasteService` still read +`client.responseCache` directly, each falling back across two different cache +keys. That predates `APICacheKeys` and should be folded into `cachedPayload`, +which already consults the persistent cache before the memory layer. + +Like Swift 6 above, this is internal and rides along with whatever release +already touches that area. Neither justifies a tag. + +## Housekeeping + +- ~~`Hutch/Hutch/App/AccountSession.swift` sits in a stray nested directory; + `Hutch/HutchTests/` is empty.~~ Done (v3.8.1, `9834b78`). Moved beside the rest + of `App/`; both stray dirs removed. No pbxproj change — the target is a + synchronized root group, so the file compiled by path all along. -- cgit v1.2.3