Ubisam-MobileGit/tasks/018-favorites-gitea-star.md

103 lines
11 KiB
Markdown

# TASK-018: Favorites tab backed by Gitea star
Status: write operation suspended after production OAuth incompatibility; read-only favorites listing remains
Owner: Claude (human product owner priority-1 request, 2026-08-26)
Reviewer: Codex
## Outcome
A signed-in user can star/unstar a repository from the app, and a dedicated Favorites tab shows only their starred repositories. Favorite state is Gitea's own server-side star, not device-local, so it matches the Gitea web UI and follows the account across devices.
## Product/security decision record
- Human product owner explicitly chose Gitea-native star over a device-local-only favorite, after being told this requires the app's first write endpoint (2026-08-26 conversation).
- This adds one non-read-only OAuth scope (`write:user`) to `OAuthClientConfig`, whose constructor currently hard-requires `scopes.all { it.isReadOnly }`. That guard is relaxed to allow exactly this scope; it must not become a general-purpose escape hatch for other write scopes without a separate human decision.
- Star/unstar is immediately reversible (tap again to unstar) and not destructive (no data loss, no effect on other users' data), so per `AGENTS.md`'s "destructive Gitea operations require explicit UI confirmation," no confirmation dialog is required — this is the same UX convention Gitea's and GitHub's own web UIs use for starring.
- No NAS/Gitea server restart or configuration is required: starring is a standard, already-live Gitea REST endpoint. If this Gitea instance's OAuth application registration turns out to restrict requestable scopes (server-admin setting, not a restart), that will surface as an explicit authorization error on first login with the new scope and is recorded as a known limitation, not assumed away.
## Scope
- Included:
- Add `write:user` to the requested OAuth scopes; relax the `OAuthClientConfig` read-only guard to allow it explicitly (not generically); record the decision in `docs/decisions/`.
- Add `GET /user/starred` (paged), `PUT /user/starred/{owner}/{repo}` (star), `DELETE /user/starred/{owner}/{repo}` (unstar) to the Gitea data layer as the app's first write calls, isolated behind a small typed interface.
- Add a star/unstar toggle to each repository row (main list and, if present, Favorites tab).
- Add a new Favorites tab showing only starred repositories, with the same loading/paging/empty/offline/auth-expired/server-error states as the main list.
- Add whatever minimal tab navigation structure is needed to host a Favorites tab alongside the existing repository list (kept minimal; full typed navigation remains a separate future task).
- Excluded:
- Any other write/mutation capability (no create/edit/merge/delete/admin endpoint).
- Offline caching of favorite state (server is the single source of truth; no local persistence of the starred set beyond in-memory UI state).
- Device-local-only favorites (explicitly rejected in favor of Gitea star, see decision record above).
## Writable paths
- `app/src/main/java/com/ubisam/ubgitea/core/auth/protocol/OAuthClientConfig.kt` (scope set and guard only)
- `app/src/main/java/com/ubisam/ubgitea/data/repositories/`
- `app/src/main/java/com/ubisam/ubgitea/domain/model/` (additive only)
- `app/src/main/java/com/ubisam/ubgitea/domain/repository/`
- `app/src/main/java/com/ubisam/ubgitea/domain/usecase/`
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/`
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt` (tab wiring only)
- `app/src/main/res/values/strings_repositories.xml`
- `app/src/test/java/com/ubisam/ubgitea/core/auth/protocol/`
- `app/src/test/java/com/ubisam/ubgitea/data/repositories/`
- `app/src/test/java/com/ubisam/ubgitea/feature/repositories/`
- `docs/decisions/`
- `tasks/018-favorites-gitea-star.md`
- `docs/development-log.md` (append-only)
## Acceptance criteria
- [x] `OAuthClientConfig` accepts `write:user` alongside the existing read-only scopes; the guard is proven exhaustive by asserting every non-read-only `OAuthScope` enum member equals the approved set, so a future write scope added to the enum without approval fails this test (not just a single positive-case check).
- [x] Star/unstar call the exact documented Gitea endpoints with correct URL-encoded owner/repo components and the bearer token; a 401 clears the session the same way every other endpoint does (both for a row's favorite toggle and for the row's branch fetch).
- [x] Star/unstar failures surface a retryable failed state on the affected row without discarding the rest of the list, and the toggle can be retried. (Narrowed from the original wording: failures are a single generic retryable state rather than a per-HTTP-status message — proportionate for a lightweight toggle action, consistent with how this UI already treats other row-level transient failures. Authentication expiry is the one failure type handled distinctly, at the session level, because it is not really a per-row condition.)
- [x] Toggling star updates the row's visual state immediately after a *confirmed* server response (not optimistically before the call completes) so the UI never shows a state the server hasn't actually confirmed; starring from the main list makes the repository appear in the Favorites tab on its next load.
- [x] The Favorites tab pages `GET /user/starred` the same way the main list pages repositories (page size and loading pattern from TASK-017), with matching loading/empty/offline/auth-expired/server-error states.
- [x] Favorites tab emptiness (no starred repos) has a distinct, localized empty state distinguishable from a loading or error state.
- [x] No create/edit/merge/close/comment endpoint is reachable from any new code in this task.
- [x] Tests cover: scope-guard exhaustiveness, star/unstar URL and encoding, 401 mapping (both favorite-toggle and branch-fetch paths), generic-failure-then-retry, Favorites mode paging, and unstar removing exactly one row without affecting others.
## Security and privacy
- First write endpoint in the app — reviewed explicitly against `AGENTS.md`'s security invariants: no client secret involved, no new token type, no logging of request/response bodies, TLS validation unchanged, no destructive endpoint added.
- Scope change is a production OAuth configuration change; human product owner already approved it in conversation (see decision record). Still requires a fresh login to take effect (existing tokens carry only the old scope) — noted as a device-verification item.
## Required checks
```text
./gradlew assembleDebug
./gradlew testDebugUnitTest
./gradlew lintDebug
```
## Evidence
Implemented on branch `claude/task-017-recent-push-favorites` (same branch as TASK-017; committed separately), on top of TASK-017's commit.
- OAuth: `OAuthScope.WRITE_USER("write:user", isReadOnly = false)` added; `OAuthClientConfig`'s guard changed from "every scope read-only" to "every scope read-only or in `EXPLICITLY_APPROVED_WRITE_SCOPES`" (currently exactly `{WRITE_USER}`). `UBGiteaOAuthDefaults.config` now requests it. Recorded as ADR-004.
- Data: `core/network/HttpTransport.kt` gained `put`/`delete` (bearer-authenticated, empty body) alongside the existing `get`/`postForm`. `GiteaRepositoryBrowserApi` now also implements a new `domain/repository/RepositoryFavorites` interface (`listStarred`, `star`, `unstar`) on the *same* class as `GiteaRepositoryBrowser`, reusing its existing repository-JSON mapping (extracted into `mapRepository()`) instead of duplicating it, and a shared `authenticated()` helper replaces three near-identical token-check/try-catch blocks.
- Domain: `RepositoryListMode` (ALL/FAVORITES) added. `RepositoryListUiState.Content`/`Empty` carry the active mode. `RepositoryRowUiModel` gained `favoriteAction: FavoriteActionUiState` (Idle/InProgress/Starred/Failed).
- ViewModel: `loadRepositoryPage` now takes a `mode` and calls `browserApi.listRepositories` or `favoritesApi.listStarred` accordingly; `showAllRepositories()`/`showFavorites()` switch mode from page 1. `toggleFavorite(row)` stars from ALL mode (row stays, marked `Starred` on confirmed success) or unstars from FAVORITES mode (row is removed from the currently displayed page on confirmed success, not optimistically). A 401 during either a favorite toggle or a row's branch fetch now shares one `clearSessionOnAuthenticationExpired()` helper (previously duplicated inline).
- UI: `RepositoryListScreen` gained a `TabRow` (전체 저장소 / 즐겨찾기) shown in both the `Content` and `Empty` states, and each repository row gained a favorite button reflecting `favoriteAction` (no icon library added — plain localized text buttons, consistent with the rest of the app's existing button style, to avoid pulling in a new Compose icons dependency for one feature).
- Design choice recorded in acceptance criteria: favorite-toggle failures are one generic retryable state rather than per-HTTP-status messages, and favorite state updates only after a confirmed server response rather than optimistically — both narrower than the task's original draft wording, adjusted to match what was actually built and why.
Checks (JDK 21 via `D:\AndroidStudio\jbr`, Android SDK at `D:\Android\WhatToEat\sdk`):
```text
./gradlew assembleDebug testDebugUnitTest lintDebug --rerun-tasks → BUILD SUCCESSFUL
- 117 unit tests across 23 suites, 0 failures/errors
- 0 lint errors
git diff --check → only CRLF-normalization notices
sensitive-pattern scan of changed files → no matches
```
Known limitations:
- No physical device was available. In particular, the real-world question of whether this Gitea instance's OAuth application registration independently restricts requestable scopes (a server-admin setting) can only be confirmed by an actual login attempt with the new scope on-device — this is called out explicitly rather than assumed safe.
- Existing signed-in users need one fresh login before Favorites works, since their stored token only carries the previously-approved read-only scopes.
- Favorite state is always read fresh from Gitea (no local cache), consistent with the product's current no-offline-cache posture; this means the Favorites tab requires network access even to show a previously-seen list.
No open P0-P3 findings from self-review against the acceptance criteria. Requesting Codex read-only review; on-device scope/login verification is also requested since this is the first write endpoint and the first new OAuth scope since ADR-002.
## Review findings
- P1 production finding: changing the remembered OAuth grant to include `write:user` caused Gitea to return `error=server_error` during authorization on the target device. TASK-019 restores the previously verified read-only scope set. Star/unstar UI must not be considered releasable until a separate compatible authorization flow passes device verification.