Summary
Add a REST endpoint that returns merged, normalized feed items for a requested set of named sources, so a feed module can populate the shared feed scaffold introduced in r1329722 (T433900) with one request and page past its first batch. Today, ReviewChanges issues up to ten Action API requests per load, merges and normalizes them in JavaScript, and cannot go past the 100 recently-edited changes handed over in getJsConfigVars().
See related task: T433900: Factor out a shared feed scaffold for dashboard modules
Technical notes
The endpoint is the server half of the feed-data contract useFeedState() already defines: { items, isLoading, error }, where every item carries a unique id. A response is { items: FeedItem[], continue: <opaque token>|null }; the client keeps isLoading and error, so a module's whole data layer becomes one loader passed to useFeedState() instead of a composable per source.
Shape: GET /personaldashboard/v0/feed/{sources}?limit=12&continue=<token>, where {sources} is a pipe-separated list. The sources identify the resource, so they belong in the path; limit and continue modulate the representation and are optional, so they stay in the query string, matching core routes like /v1/page/{title}/history?older_than=. Keeping the continue token out of the path also avoids percent-encoded slashes in a path segment, which proxies handle inconsistently.
Sources come from a registry keyed by name so a consumer outside PersonalDashboard can register one and reuse the scaffold without patching this extension; the three Review Changes sources are the first implementations, and ActiveDiscussions' DiscussionTools query is a candidate to follow.
Server-side, this moves work the client currently repeats: the recentchanges param building and continuation loop in useRecentChangesFeed.js, normalizeFeedItem(), and selectEvenlyAcrossFeeds(). The merge belongs on the server deliberately — an even split across sources with title dedup and backfill when a source runs dry is only decidable where all the sources are, and it's what lets one request answer a multi-source feed. ReviewChanges::getRecentlyEditedItems() already builds the FeedItem shape through ChangesListQuery, so the normalization contract exists — it just needs to become the endpoint's response schema, documented and versioned as something other extensions can depend on.
Ordering is deterministic newest-first. The random sampling the three composables do today via getRandomItems() can't survive a continue token — you can't paginate coherently through a random sample — so it comes out of the fetch path. getRandomItems() stays exported from ext.personalDashboard.common, and a client that wants a shuffled view asks for a page larger than it displays and samples what it got. ReviewChanges should do exactly that when it migrates: its sampling isn't decorative, it spreads reviewers across different changes so two people don't independently review the same top-of-recentchanges edit. Requesting limit=12 and rendering all twelve would quietly drop that.
Pagination is the reason the cap goes away: the continue token encodes each source's own offset, so the full-list view's "Load more" control asks for the next page rather than slicing a fixed batch that was capped at 100 before it reached the browser. $wgPersonalDashboardRecentlyEditedItems can then shrink to a first-page payload or be dropped, taking those items out of the HTML.
Responses are user-scoped (watchlist, recently-edited, and the ORES/patrol filters that vary by user rights), so this is private, session-authenticated, and consequently uncacheable at the edge.
Acceptance criteria
- A versioned REST endpoint returns merged, normalized feed items for a requested set of registered sources, ordered newest-first, with an opaque continue token and a documented item schema.
- Review Changes loads its feed from the endpoint in a single request, with client-side param building, pagination, merging, and normalization removed, and over-fetches so it can keep sampling client-side.
- "Load more" fetches subsequent pages rather than stopping at the initial batch
- An extension outside PersonalDashboard can register a feed source without patching PersonalDashboard. Say, we could introduce a new Feed interface with a getItems() method that returns a FeedItem – essentially porting the current feedHelper.js to the backend.