TASK-018, human product owner priority-1 request. Adds write:user OAuth scope (ADR-004) and relaxes OAuthClientConfig's read-only guard to an explicit, hard-coded write-scope allow-list rather than a general write-scope allowance. GiteaRepositoryBrowserApi gains star/ unstar/listStarred via a new RepositoryFavorites interface, reusing its existing repository mapping. RepositoryListViewModel tracks an ALL/FAVORITES mode; toggling favorite confirms against the server before updating UI state rather than acting optimistically. No create/edit/merge/close/comment endpoint is introduced. Favorite state is never cached locally. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
103 lines
10 KiB
Markdown
103 lines
10 KiB
Markdown
# TASK-018: Favorites tab backed by Gitea star
|
|
|
|
Status: implemented; all required checks pass; pending Codex review and device verification
|
|
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
|
|
|
|
None yet.
|