diff --git a/deployments/api/API_REFERENCE.md b/deployments/api/API_REFERENCE.md
index ee975323..fb1b8683 100644
--- a/deployments/api/API_REFERENCE.md
+++ b/deployments/api/API_REFERENCE.md
@@ -92,7 +92,12 @@
**Response:** `200`
-- array[MergeCandidateView]
+- `items`: array[MergeCandidateListItemView]
+- `total_count`: integer
+- `page`: integer
+- `page_size`: integer
+- `status_counts`: object[string, integer]
+- `total_pages`: integer
---
@@ -137,6 +142,7 @@
- `last_updated_by_id`: integer
- `reviewed_at`: string | null
- `reviewed_by_id`: integer | null
+- `name`: string | null
- `compare`: array[FieldComparisonView]
---
diff --git a/deployments/api/src/stitch/api/db/merge_candidate_actions.py b/deployments/api/src/stitch/api/db/merge_candidate_actions.py
index b93a712c..7e4337e8 100644
--- a/deployments/api/src/stitch/api/db/merge_candidate_actions.py
+++ b/deployments/api/src/stitch/api/db/merge_candidate_actions.py
@@ -1,10 +1,10 @@
from __future__ import annotations
-from collections.abc import Collection, Sequence
+from collections.abc import Collection, Mapping, Sequence
from datetime import datetime, timezone
from typing import Any
-from sqlalchemy import select
+from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -19,12 +19,15 @@
FieldComparisonView,
MergeCandidateCreateRequest,
MergeCandidateDetailView,
+ MergeCandidateListItemView,
+ MergeCandidatePage,
+ MergeCandidateQueryParams,
MergeCandidateReviewRequest,
MergeCandidateStatus,
MergeCandidateView,
)
from stitch.api.observability.context import named_query
-from stitch.ogsi.model import OGFieldSource
+from stitch.ogsi.model import SOURCE_PRIORITY, OGFieldResource, OGFieldSource
from stitch.ogsi.model.og_field import OilGasFieldBase
from stitch.ogsi.model.types import OGSISrcKey
@@ -35,7 +38,7 @@
ResourceModel,
)
from .og_field_resource_actions import apply_resource_merge
-from .utils import coalesce_resources_with_sources
+from .utils import coalesce_resources, coalesce_resources_with_sources
def _normalize_resource_ids(resource_ids: Sequence[int]) -> list[int]:
@@ -172,14 +175,72 @@ async def _default_source_priority(session: AsyncSession) -> dict[str, int]:
return {source: priority for source, priority in rows.all()}
+def _ordered_resource_ids(model: MergeCandidateModel) -> list[int]:
+ return [item.resource_id for item in sorted(model.items, key=lambda i: i.position)]
+
+
+def _source_rank(source: OGSISrcKey) -> int:
+ return (
+ SOURCE_PRIORITY.index(source)
+ if source in SOURCE_PRIORITY
+ else len(SOURCE_PRIORITY)
+ )
+
+
+def _candidate_name(
+ model: MergeCandidateModel, coalesced: Mapping[int, OGFieldResource]
+) -> str | None:
+ """Display name for a candidate; see ``MergeCandidateListItemView.name``.
+
+ ``coalesced`` must hold the candidate's resources and, once merged, its
+ merged resource. Source resources are ranked by the global
+ ``SOURCE_PRIORITY`` (the same rule the frontend used to apply), with ties
+ going to the earlier resource.
+ """
+ if model.merged_resource_id is not None:
+ merged = coalesced[model.merged_resource_id].view
+ if merged is not None and merged.name is not None:
+ return merged.name
+
+ best: tuple[int, str] | None = None
+ for resource_id in _ordered_resource_ids(model):
+ provenance = coalesced[resource_id].provenance.get("name")
+ if provenance is None:
+ continue
+ name, source, _source_pk = provenance
+ rank = _source_rank(source)
+ if best is None or rank < best[0]:
+ best = (rank, name)
+ return None if best is None else best[1]
+
+
+def _name_resource_ids(models: Sequence[MergeCandidateModel]) -> list[int]:
+ """Every resource ``_candidate_name`` reads for these candidates."""
+ ids: list[int] = []
+ for model in models:
+ ids.extend(_ordered_resource_ids(model))
+ if model.merged_resource_id is not None:
+ ids.append(model.merged_resource_id)
+ return list(dict.fromkeys(ids))
+
+
+def _candidate_to_list_item_view(
+ model: MergeCandidateModel, name: str | None
+) -> MergeCandidateListItemView:
+ return MergeCandidateListItemView(
+ **_candidate_to_view(model).model_dump(), name=name
+ )
+
+
def _candidate_to_detail_view(
model: MergeCandidateModel,
+ name: str | None,
compare: Sequence[FieldComparisonView],
) -> MergeCandidateDetailView:
- # MergeCandidateDetailView is MergeCandidateView + `compare`; reuse the base
+ # MergeCandidateDetailView is the list item + `compare`; reuse the base
# mapping so the shared fields stay defined in one place.
return MergeCandidateDetailView(
- **_candidate_to_view(model).model_dump(),
+ **_candidate_to_list_item_view(model, name).model_dump(),
compare=list(compare),
)
@@ -200,15 +261,64 @@ async def _load_candidate_model(
return model
-async def list_merge_candidates(session: AsyncSession) -> list[MergeCandidateView]:
+async def list_merge_candidates(
+ session: AsyncSession,
+ params: MergeCandidateQueryParams,
+ licensed_sources: Collection[OGSISrcKey] | None = None,
+) -> MergeCandidatePage:
+ """One page of merge candidates, with whole-queue counts by status."""
+ filtered = select(MergeCandidateModel.id)
+ if params.status:
+ filtered = filtered.where(MergeCandidateModel.status.in_(params.status))
+
+ with named_query("merge_candidates.list.count"):
+ total = (
+ await session.scalar(select(func.count()).select_from(filtered.subquery()))
+ ) or 0
+
+ sort_column = getattr(MergeCandidateModel, params.sort_by)
+ if params.sort_order == "asc":
+ order_by = (sort_column.asc().nulls_last(), MergeCandidateModel.id.asc())
+ else:
+ order_by = (sort_column.desc().nulls_last(), MergeCandidateModel.id.desc())
stmt = (
select(MergeCandidateModel)
.options(selectinload(MergeCandidateModel.items))
- .order_by(MergeCandidateModel.created.desc())
+ .where(MergeCandidateModel.id.in_(filtered))
+ .order_by(*order_by)
+ .limit(params.limit)
+ .offset(params.offset)
)
with named_query("merge_candidates.list"):
candidates = (await session.scalars(stmt)).all()
- return [_candidate_to_view(candidate) for candidate in candidates]
+
+ with named_query("merge_candidates.list.status_counts"):
+ rows = await session.execute(
+ select(MergeCandidateModel.status, func.count()).group_by(
+ MergeCandidateModel.status
+ )
+ )
+ status_counts = dict.fromkeys(MergeCandidateStatus, 0)
+ for status, count in rows.all():
+ status_counts[MergeCandidateStatus(status)] = count
+
+ with named_query("merge_candidates.list.names"):
+ coalesced = await coalesce_resources(
+ session, _name_resource_ids(candidates), licensed_sources
+ )
+
+ return MergeCandidatePage(
+ items=[
+ _candidate_to_list_item_view(
+ candidate, _candidate_name(candidate, coalesced)
+ )
+ for candidate in candidates
+ ],
+ total_count=total,
+ page=params.page,
+ page_size=params.page_size,
+ status_counts=status_counts,
+ )
async def get_merge_candidate(
@@ -219,9 +329,7 @@ async def get_merge_candidate(
with named_query("merge_candidates.detail.load"):
candidate = await _load_candidate_model(session, candidate_id)
- resource_ids = [
- item.resource_id for item in sorted(candidate.items, key=lambda i: i.position)
- ]
+ resource_ids = _ordered_resource_ids(candidate)
# Computed live: repointed (post-merge) resources coalesce to a null-shell
# here, so an APPROVED candidate's `compare` reflects the emptied originals.
@@ -251,7 +359,18 @@ async def get_merge_candidate(
resource_views = [by_id[rid].view for rid in resource_ids]
compare = _build_comparison(resource_views, sources_with_priority)
- return _candidate_to_detail_view(candidate, compare)
+ name_sources: dict[int, OGFieldResource] = dict(by_id)
+ if candidate.merged_resource_id is not None:
+ with named_query("merge_candidates.detail.merged_name"):
+ name_sources.update(
+ await coalesce_resources(
+ session, [candidate.merged_resource_id], licensed_sources
+ )
+ )
+
+ return _candidate_to_detail_view(
+ candidate, _candidate_name(candidate, name_sources), compare
+ )
async def create_merge_candidate(
diff --git a/deployments/api/src/stitch/api/entities.py b/deployments/api/src/stitch/api/entities.py
index 24fe7f42..26e4e459 100644
--- a/deployments/api/src/stitch/api/entities.py
+++ b/deployments/api/src/stitch/api/entities.py
@@ -184,6 +184,49 @@ class MergeCandidateView(BaseModel):
reviewed_by_id: int | None = None
+class MergeCandidateListItemView(MergeCandidateView):
+ """A merge candidate as read back by the list and detail endpoints.
+
+ ``name`` is the candidate's display name: the merged resource's name once
+ the candidate is approved (its source resources are emptied by the merge),
+ otherwise the name from the best-ranked source across its resources. It is
+ null when no licensed source carries a name.
+ """
+
+ name: str | None = None
+
+
+class MergeCandidateFilterParams(BaseModel):
+ """Repeat ``status`` once per value (``?status=PENDING&status=DENIED``) to
+ match any of them. Omitted, every status is returned."""
+
+ status: list[MergeCandidateStatus] | None = None
+
+
+class MergeCandidateSortParams(BaseModel):
+ # `id` is always appended in the same direction as a tiebreak, so pages
+ # stay stable when candidates share a timestamp (bulk creation).
+ sort_by: Literal["created", "reviewed_at"] = "created"
+ sort_order: Literal["asc", "desc"] = "desc"
+
+
+class MergeCandidateQueryParams(
+ PaginationParams, MergeCandidateFilterParams, MergeCandidateSortParams
+):
+ pass
+
+
+class MergeCandidatePage(PaginatedResponse[MergeCandidateListItemView]):
+ """A page of merge candidates.
+
+ ``status_counts`` covers the whole queue regardless of the ``status``
+ filter, so a reviewer always sees the full workload. Every status is
+ present, zero when none exist.
+ """
+
+ status_counts: dict[MergeCandidateStatus, int]
+
+
class ComparisonValueView(OGFieldSourceValueView):
"""A source's value in a merge comparison, tagged with ``resource_id`` --
the candidate resource the source is currently attached to.
@@ -228,5 +271,5 @@ class FieldComparisonView(BaseModel):
values: list[ComparisonValueView]
-class MergeCandidateDetailView(MergeCandidateView):
+class MergeCandidateDetailView(MergeCandidateListItemView):
compare: list[FieldComparisonView]
diff --git a/deployments/api/src/stitch/api/routers/oil_gas_fields.py b/deployments/api/src/stitch/api/routers/oil_gas_fields.py
index 9e0c5c04..08a13008 100644
--- a/deployments/api/src/stitch/api/routers/oil_gas_fields.py
+++ b/deployments/api/src/stitch/api/routers/oil_gas_fields.py
@@ -16,6 +16,8 @@
OGFieldFilterOptionsResponse,
MergeCandidateCreateRequest,
MergeCandidateDetailView,
+ MergeCandidatePage,
+ MergeCandidateQueryParams,
MergeCandidateReviewRequest,
MergeCandidateView,
OGFieldQueryParams,
@@ -101,13 +103,21 @@ async def get_resource_filter_options(
@router.get(
"/merge-candidates",
- response_model=list[MergeCandidateView],
+ response_model=MergeCandidatePage,
dependencies=[Depends(require_permissions(MERGE_CANDIDATE_READ))],
)
async def list_merge_candidates(
- *, uow: UnitOfWorkDep, _user: CurrentUser
-) -> list[MergeCandidateView]:
- return await merge_candidate_actions.list_merge_candidates(session=uow.session)
+ *,
+ uow: UnitOfWorkDep,
+ _user: CurrentUser,
+ claims: Claims,
+ params: Annotated[MergeCandidateQueryParams, Query()],
+) -> MergeCandidatePage:
+ return await merge_candidate_actions.list_merge_candidates(
+ session=uow.session,
+ params=params,
+ licensed_sources=licensed_sources(claims),
+ )
@router.get(
diff --git a/deployments/api/tests/observability/test_query_name_actions.py b/deployments/api/tests/observability/test_query_name_actions.py
index 619d2af3..450ac947 100644
--- a/deployments/api/tests/observability/test_query_name_actions.py
+++ b/deployments/api/tests/observability/test_query_name_actions.py
@@ -27,6 +27,7 @@
from stitch.api.db.model.oil_gas_field_source_value import ATTRIBUTE_NAMES
from stitch.api.entities import (
MergeCandidateCreateRequest,
+ MergeCandidateQueryParams,
MergeCandidateReviewRequest,
OGFieldQueryParams,
User,
@@ -369,8 +370,16 @@ async def test_merge_candidate_action_labels(
)
captured_query_events.clear()
- await mca.list_merge_candidates(session)
- _assert_labels(captured_query_events, {"merge_candidates.list"})
+ await mca.list_merge_candidates(session, MergeCandidateQueryParams())
+ _assert_labels(
+ captured_query_events,
+ {
+ "merge_candidates.list.count",
+ "merge_candidates.list",
+ "merge_candidates.list.status_counts",
+ "merge_candidates.list.names",
+ },
+ )
captured_query_events.clear()
await mca.get_merge_candidate(session, approved.id)
diff --git a/deployments/api/tests/routers/test_merge_candidates_integration.py b/deployments/api/tests/routers/test_merge_candidates_integration.py
index b1d9ace7..f4fbf991 100644
--- a/deployments/api/tests/routers/test_merge_candidates_integration.py
+++ b/deployments/api/tests/routers/test_merge_candidates_integration.py
@@ -1,14 +1,19 @@
-"""Integration tests for the merge-candidate detail endpoint (real SQLite)."""
+"""Integration tests for the merge-candidate list and detail endpoints (real SQLite)."""
from collections.abc import Sequence
+from datetime import datetime, timezone
from httpx import AsyncClient
import pytest
-from sqlalchemy import select
+from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from tests.factories import ResourceCreateFactory
-from stitch.api.db.model import MembershipModel, OGFieldResourceSourcePriority
+from stitch.api.db.model import (
+ MembershipModel,
+ MergeCandidateModel,
+ OGFieldResourceSourcePriority,
+)
from stitch.ogsi.model import OGFieldResource, OGFieldSource
@@ -534,3 +539,162 @@ async def test_composite_resource_matches_when_winners_agree(
(id_a, "gem", "Bar"),
(id_b, "rmi", "Foo"),
}
+
+
+async def _create_disjoint_candidates(
+ client: AsyncClient, fact: ResourceCreateFactory, count: int
+) -> list[int]:
+ """``count`` two-resource candidates sharing no resources, in creation order."""
+ candidate_ids = []
+ for i in range(count):
+ id_a = await _create_resource(client, fact, f"Field {i}")
+ id_b = await _create_resource(client, fact, f"Field {i} dup")
+ candidate_ids.append(await _create_candidate(client, [id_a, id_b]))
+ return candidate_ids
+
+
+class TestMergeCandidateListIntegration:
+ @pytest.mark.anyio
+ async def test_pages_are_stable_when_candidates_share_a_timestamp(
+ self,
+ integration_client: AsyncClient,
+ og_create_res_fact: ResourceCreateFactory,
+ integration_session_factory: async_sessionmaker[AsyncSession],
+ ):
+ candidate_ids = await _create_disjoint_candidates(
+ integration_client, og_create_res_fact, 5
+ )
+ # Bulk creation can stamp many candidates with the same time; the id
+ # tiebreak must still give every candidate exactly one page.
+ async with integration_session_factory() as session:
+ await session.execute(
+ update(MergeCandidateModel).values(
+ created=datetime(2026, 1, 1, tzinfo=timezone.utc)
+ )
+ )
+ await session.commit()
+
+ seen = []
+ for page in (1, 2, 3):
+ resp = await integration_client.get(
+ "/oil-gas-fields/merge-candidates",
+ params={"page": page, "page_size": 2},
+ )
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ assert body["total_count"] == 5
+ assert body["total_pages"] == 3
+ seen.extend(item["id"] for item in body["items"])
+
+ assert seen == sorted(candidate_ids, reverse=True)
+
+ @pytest.mark.anyio
+ async def test_status_filter_narrows_items_but_not_counts(
+ self,
+ integration_client: AsyncClient,
+ og_create_res_fact: ResourceCreateFactory,
+ ):
+ approved_id, denied_id, pending_id = await _create_disjoint_candidates(
+ integration_client, og_create_res_fact, 3
+ )
+ for candidate_id, action in ((approved_id, "approve"), (denied_id, "deny")):
+ resp = await integration_client.post(
+ f"/oil-gas-fields/merge-candidates/{candidate_id}/{action}"
+ )
+ assert resp.status_code == 200, resp.text
+
+ pending = await integration_client.get(
+ "/oil-gas-fields/merge-candidates", params={"status": "PENDING"}
+ )
+ assert pending.status_code == 200, pending.text
+ assert [item["id"] for item in pending.json()["items"]] == [pending_id]
+ assert pending.json()["total_count"] == 1
+ assert pending.json()["status_counts"] == {
+ "PENDING": 1,
+ "APPROVED": 1,
+ "DENIED": 1,
+ }
+
+ reviewed = await integration_client.get(
+ "/oil-gas-fields/merge-candidates",
+ params=[("status", "APPROVED"), ("status", "DENIED")],
+ )
+ assert {item["id"] for item in reviewed.json()["items"]} == {
+ approved_id,
+ denied_id,
+ }
+
+ unfiltered = await integration_client.get("/oil-gas-fields/merge-candidates")
+ assert unfiltered.json()["total_count"] == 3
+
+ @pytest.mark.anyio
+ @pytest.mark.parametrize("sort_order", ["asc", "desc"])
+ async def test_sort_by_reviewed_at_puts_unreviewed_last(
+ self,
+ integration_client: AsyncClient,
+ og_create_res_fact: ResourceCreateFactory,
+ sort_order: str,
+ ):
+ first, second, unreviewed = await _create_disjoint_candidates(
+ integration_client, og_create_res_fact, 3
+ )
+ for candidate_id in (first, second):
+ resp = await integration_client.post(
+ f"/oil-gas-fields/merge-candidates/{candidate_id}/deny"
+ )
+ assert resp.status_code == 200, resp.text
+
+ resp = await integration_client.get(
+ "/oil-gas-fields/merge-candidates",
+ params={"sort_by": "reviewed_at", "sort_order": sort_order},
+ )
+ assert resp.status_code == 200, resp.text
+ reviewed = [first, second] if sort_order == "asc" else [second, first]
+ assert [item["id"] for item in resp.json()["items"]] == [
+ *reviewed,
+ unreviewed,
+ ]
+
+ @pytest.mark.anyio
+ async def test_items_carry_display_names(
+ self,
+ integration_client: AsyncClient,
+ og_create_res_fact: ResourceCreateFactory,
+ ):
+ id_a = await _create_resource(integration_client, og_create_res_fact, "Ghawar")
+ id_b = await _create_resource(integration_client, og_create_res_fact, "Burgan")
+ candidate_id = await _create_candidate(integration_client, [id_a, id_b])
+
+ resp = await integration_client.get("/oil-gas-fields/merge-candidates")
+ assert resp.status_code == 200, resp.text
+ (item,) = resp.json()["items"]
+ # Same source on both resources: the tie goes to the first resource.
+ assert item["name"] == "Ghawar"
+
+ # After approval the originals are emptied; the merged resource names it.
+ approve = await integration_client.post(
+ f"/oil-gas-fields/merge-candidates/{candidate_id}/approve"
+ )
+ assert approve.status_code == 200, approve.text
+ resp = await integration_client.get("/oil-gas-fields/merge-candidates")
+ (item,) = resp.json()["items"]
+ assert item["name"] == "Ghawar"
+
+ @pytest.mark.anyio
+ @pytest.mark.parametrize(
+ "params",
+ [
+ {"status": "MAYBE"},
+ {"sort_by": "name"},
+ {"sort_order": "sideways"},
+ {"page": 0},
+ {"page_size": 201},
+ ],
+ )
+ async def test_rejects_invalid_params(
+ self, integration_client: AsyncClient, params: dict
+ ):
+ resp = await integration_client.get(
+ "/oil-gas-fields/merge-candidates", params=params
+ )
+ assert resp.status_code == 422, resp.text
diff --git a/deployments/api/tests/routers/test_route_permissions.py b/deployments/api/tests/routers/test_route_permissions.py
index ebaa09a4..4c65225d 100644
--- a/deployments/api/tests/routers/test_route_permissions.py
+++ b/deployments/api/tests/routers/test_route_permissions.py
@@ -17,6 +17,7 @@
from stitch.api.auth import get_token_claims
from stitch.api.db.config import get_uow
+from stitch.api.entities import MergeCandidatePage, MergeCandidateStatus
from stitch.api.main import app
@@ -144,8 +145,16 @@ async def test_merge_candidate_read_permission_allows_list_route(
app.dependency_overrides[get_uow] = _uow_override(mock_uow)
with patch("stitch.api.routers.oil_gas_fields.merge_candidate_actions") as actions:
- actions.list_merge_candidates = AsyncMock(return_value=[])
+ actions.list_merge_candidates = AsyncMock(
+ return_value=MergeCandidatePage(
+ items=[],
+ total_count=0,
+ page=1,
+ page_size=50,
+ status_counts=dict.fromkeys(MergeCandidateStatus, 0),
+ )
+ )
response = await async_client.get("/oil-gas-fields/merge-candidates")
assert response.status_code == 200
- assert response.json() == []
+ assert response.json()["items"] == []
diff --git a/deployments/api/tests/test_merge_candidate_actions.py b/deployments/api/tests/test_merge_candidate_actions.py
index b94c9a21..333ef8c4 100644
--- a/deployments/api/tests/test_merge_candidate_actions.py
+++ b/deployments/api/tests/test_merge_candidate_actions.py
@@ -9,6 +9,7 @@
from stitch.api.entities import (
MergeCandidateCreateRequest,
MergeCandidateDetailView,
+ MergeCandidateQueryParams,
MergeCandidateReviewRequest,
MergeCandidateStatus,
)
@@ -52,6 +53,7 @@ class FakeMergedResource:
class FakeSession:
scalar_result: object | None = None
scalars_result: list[object] = field(default_factory=list)
+ execute_rows: list[tuple] = field(default_factory=list)
added: list[object] = field(default_factory=list)
added_all: list[object] = field(default_factory=list)
deleted: list[object] = field(default_factory=list)
@@ -64,6 +66,9 @@ async def scalar(self, _stmt):
async def scalars(self, _stmt):
return SimpleNamespace(all=lambda: list(self.scalars_result))
+ async def execute(self, _stmt):
+ return SimpleNamespace(all=lambda: list(self.execute_rows))
+
def add(self, obj):
self.added.append(obj)
@@ -116,8 +121,18 @@ def test_candidate_to_view_sorts_items_by_position():
assert view.id == 1
+def _named(name: str | None, source: str = "rmi", source_pk: int = 1):
+ """A coalesced resource whose only known field is ``name``."""
+ return SimpleNamespace(
+ view=OilGasFieldBase(name=name, country=None),
+ provenance={"name": None if name is None else (name, source, source_pk)},
+ )
+
+
@pytest.mark.anyio
-async def test_list_merge_candidates_returns_views_in_query_order():
+async def test_list_merge_candidates_returns_page_with_names_and_counts(
+ monkeypatch,
+):
first = FakeCandidate(
id=2,
status=MergeCandidateStatus.PENDING,
@@ -125,16 +140,69 @@ async def test_list_merge_candidates_returns_views_in_query_order():
)
second = FakeCandidate(
id=1,
- status=MergeCandidateStatus.DENIED,
+ status=MergeCandidateStatus.APPROVED,
items=[FakeItem(20, 0), FakeItem(21, 1)],
+ merged_resource_id=30,
+ )
+ session = FakeSession(
+ scalar_result=7,
+ scalars_result=[first, second],
+ execute_rows=[("PENDING", 5), ("APPROVED", 2)],
)
- session = FakeSession(scalars_result=[first, second])
- views = await mca.list_merge_candidates(session)
+ async def fake_coalesce(session_arg, resource_ids, licensed):
+ assert resource_ids == [18, 19, 20, 21, 30]
+ assert licensed == ["rmi"]
+ return {
+ 18: _named("From WM", source="wm"),
+ 19: _named("From RMI", source="rmi"),
+ # Merged-away originals are null shells; the merged resource names it.
+ 20: _named(None),
+ 21: _named(None),
+ 30: _named("Merged"),
+ }
+
+ monkeypatch.setattr(mca, "coalesce_resources", fake_coalesce)
- assert [view.id for view in views] == [2, 1]
- assert views[0].resource_ids == [18, 19]
- assert views[1].status == MergeCandidateStatus.DENIED
+ page = await mca.list_merge_candidates(
+ session, MergeCandidateQueryParams(page=1, page_size=2), ["rmi"]
+ )
+
+ assert [view.id for view in page.items] == [2, 1]
+ assert page.items[0].resource_ids == [18, 19]
+ # Best-ranked source wins regardless of resource order.
+ assert page.items[0].name == "From RMI"
+ assert page.items[1].name == "Merged"
+ assert page.total_count == 7
+ assert page.total_pages == 4
+ # Every status is reported, including ones with no candidates.
+ assert page.status_counts == {
+ MergeCandidateStatus.PENDING: 5,
+ MergeCandidateStatus.APPROVED: 2,
+ MergeCandidateStatus.DENIED: 0,
+ }
+
+
+def test_candidate_name_breaks_rank_ties_by_resource_position():
+ candidate = FakeCandidate(
+ id=1,
+ status=MergeCandidateStatus.PENDING,
+ items=[FakeItem(19, 1), FakeItem(18, 0)],
+ )
+ coalesced = {18: _named("First"), 19: _named("Second")}
+
+ assert mca._candidate_name(candidate, coalesced) == "First"
+
+
+def test_candidate_name_is_none_without_a_licensed_name():
+ candidate = FakeCandidate(
+ id=1,
+ status=MergeCandidateStatus.PENDING,
+ items=[FakeItem(18, 0), FakeItem(19, 1)],
+ )
+ coalesced = {18: _named(None), 19: _named(None)}
+
+ assert mca._candidate_name(candidate, coalesced) is None
@pytest.mark.anyio
@@ -561,6 +629,7 @@ async def fake_coalesce(session_arg, resource_ids, licensed):
rid: SimpleNamespace(
source_data=[SimpleNamespace(source="rmi", id=rid, name=f"name-{rid}")],
view=OilGasFieldBase(name=f"name-{rid}", country="USA"),
+ provenance={"name": (f"name-{rid}", "rmi", rid)},
)
for rid in resource_ids
}
@@ -578,6 +647,7 @@ async def fake_default_priority(session_arg):
assert isinstance(view, MergeCandidateDetailView)
assert view.resource_ids == [18, 19]
+ assert view.name == "name-18"
# resources detail objects are gone; compare carries the per-source values
assert not hasattr(view, "resources")
assert [c.field for c in view.compare] == list(OilGasFieldBase.model_fields)
@@ -607,21 +677,29 @@ async def fake_load_candidate_model(session_arg, candidate_id):
async def fake_coalesce(session_arg, resource_ids, licensed):
return {
rid: SimpleNamespace(
- source_data=[], view=OilGasFieldBase(name=None, country=None)
+ source_data=[],
+ view=OilGasFieldBase(name=None, country=None),
+ provenance={"name": None},
)
for rid in resource_ids
}
+ async def fake_coalesce_merged(session_arg, resource_ids, licensed):
+ assert resource_ids == [31]
+ return {31: _named("Merged")}
+
async def fake_default_priority(session_arg):
return {"rmi": 1, "gem": 2, "wm": 3, "llm": 4}
monkeypatch.setattr(mca, "_load_candidate_model", fake_load_candidate_model)
monkeypatch.setattr(mca, "coalesce_resources_with_sources", fake_coalesce)
monkeypatch.setattr(mca, "_default_source_priority", fake_default_priority)
+ monkeypatch.setattr(mca, "coalesce_resources", fake_coalesce_merged)
view = await mca.get_merge_candidate(AsyncMock(), candidate_id=2)
assert view.merged_resource_id == 31
+ assert view.name == "Merged"
# every resource is null on every field -> they all agree (match), no values
assert all(c.status == "match" for c in view.compare)
assert all(c.values == [] for c in view.compare)
diff --git a/deployments/entity-linkage/src/stitch/entity_linkage/client.py b/deployments/entity-linkage/src/stitch/entity_linkage/client.py
index fb163927..6c11a9ff 100644
--- a/deployments/entity-linkage/src/stitch/entity_linkage/client.py
+++ b/deployments/entity-linkage/src/stitch/entity_linkage/client.py
@@ -76,7 +76,11 @@ async def iter_oil_gas_fields(
yield self._to_candidate(item)
async def list_merge_candidates(self) -> list[dict[str, Any]]:
- return await self._client.list_merge_candidates()
+ """Every merge candidate of every status, gathered page by page.
+
+ De-duplication needs reviewed candidates too, so no status filter.
+ """
+ return [item async for item in self._client.iter_merge_candidates()]
@staticmethod
def _to_candidate(item: dict[str, Any]) -> FieldCandidate:
diff --git a/deployments/entity-linkage/tests/test_client.py b/deployments/entity-linkage/tests/test_client.py
index 9e234eec..c1198fa7 100644
--- a/deployments/entity-linkage/tests/test_client.py
+++ b/deployments/entity-linkage/tests/test_client.py
@@ -71,6 +71,42 @@ def handler(request: httpx.Request) -> httpx.Response:
await client.aclose()
+@pytest.mark.anyio
+async def test_list_merge_candidates_gathers_every_page_of_every_status(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setenv(STITCH_CLIENT_BEARER_TOKEN_ENV_VAR, "token-123")
+ requests: list[list[tuple[str, str]]] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ params = list(request.url.params.multi_items())
+ requests.append(params)
+ page = int(request.url.params["page"])
+ # A full first page, then one more candidate.
+ ids = range(200) if page == 1 else [200]
+ return httpx.Response(
+ 200,
+ json={
+ "items": [{"id": i} for i in ids],
+ "total_count": 201,
+ "page": page,
+ "page_size": 200,
+ "total_pages": 2,
+ },
+ )
+
+ client = make_client(handler)
+
+ candidates = await client.list_merge_candidates()
+
+ assert [c["id"] for c in candidates] == list(range(201))
+ # No status filter: de-dupe must see reviewed candidates too.
+ assert requests == [
+ [("page", "1"), ("page_size", "200")],
+ [("page", "2"), ("page_size", "200")],
+ ]
+
+
@pytest.mark.anyio
async def test_post_merge_sends_current_branch_payload_shape(
monkeypatch: pytest.MonkeyPatch,
diff --git a/deployments/stitch-frontend/README.md b/deployments/stitch-frontend/README.md
index b6f280c4..cb01ceda 100644
--- a/deployments/stitch-frontend/README.md
+++ b/deployments/stitch-frontend/README.md
@@ -12,6 +12,7 @@ React frontend application styled with Tailwind CSS and built with Vite.
- [Testing](#testing)
- [Queries](#queries)
- [List view URLs](#list-view-urls)
+- [Merge review URLs](#merge-review-urls)
- [Resource Detail View](#resource-detail-view)
## Tech Stack
@@ -217,6 +218,31 @@ Notes:
The schema lives in `src/config/listParams.js`.
+## Merge review URLs
+
+The Merge Review queue (`/merge-candidate-review`) encodes its view the same way, so a copied link opens the same page of the queue for everyone.
+
+| Param | Values | Omitted when |
+| ------------------------ | --------------------------------------------------------- | ----------------------------- |
+| `page` | integer ≥ 1 | page 1 |
+| `page_size` | 10, 25, 50, 100 | 25 |
+| `status` | `PENDING`, `APPROVED`, `DENIED` (repeat per value), `all` | only `PENDING` is selected |
+| `sort_by` + `sort_order` | `created` or `reviewed_at`, with `asc` or `desc` | newest first (`created desc`) |
+
+Example — approved and denied candidates, most recently reviewed first, page 2:
+
+```
+/merge-candidate-review?page=2&status=APPROVED&status=DENIED&sort_by=reviewed_at&sort_order=desc
+```
+
+Notes:
+
+- The queue shows pending candidates by default, so a bare `/merge-candidate-review` is the review work still to do. Clearing every status in the Status filter shows all of them, written as `status=all`.
+- The page size is not remembered between visits, unlike the resources list.
+- Unknown statuses are dropped, and a malformed `page`, `page_size` or sort falls back to its default. A `page` past the end steps back to the last page.
+
+The schema lives in `src/config/mergeReviewParams.js`.
+
## API
To temporarily run the API in development mode, outside Docker:
diff --git a/deployments/stitch-frontend/src/components/MergeSourceComparison.jsx b/deployments/stitch-frontend/src/components/MergeSourceComparison.jsx
index 0fd5461f..b0487ffd 100644
--- a/deployments/stitch-frontend/src/components/MergeSourceComparison.jsx
+++ b/deployments/stitch-frontend/src/components/MergeSourceComparison.jsx
@@ -167,9 +167,9 @@ function ColumnHeader({ resourceId, sourceDetails, index }) {
// The loaded layout with placeholders in the cells. Headers and field labels are
// real: resourceIds is a prop and FIELD_META is static, so neither changes when
// the data lands. The trailing bar stands in for the collapsed accordion summary.
-// Source mixes are real too once their own query has data -- usually already,
-// since the queue loads it for candidate names -- and the placeholder is the
-// same height, so showing them early causes no layout shift.
+// Source mixes are real too once their own query has data -- e.g. when
+// returning to a candidate whose details are cached -- and the placeholder is
+// the same height, so showing them early causes no layout shift.
function ComparisonSkeleton({ resourceIds, sourceDetails }) {
return (
diff --git a/deployments/stitch-frontend/src/components/MergeSourceComparison.test.jsx b/deployments/stitch-frontend/src/components/MergeSourceComparison.test.jsx
index e7ce3df1..989963d5 100644
--- a/deployments/stitch-frontend/src/components/MergeSourceComparison.test.jsx
+++ b/deployments/stitch-frontend/src/components/MergeSourceComparison.test.jsx
@@ -260,10 +260,10 @@ describe("source mix per resource column", () => {
});
it("shows cached source mixes even while the comparison itself loads", () => {
- // The queue loads these details for candidate names, so they are usually
- // cached before the comparison arrives. Showing them early costs no
- // layout shift (the placeholder is the same height). The loading layout
- // is hidden from screen readers, hence { hidden: true }.
+ // The details can be cached before the comparison arrives (e.g. when
+ // returning to a candidate). Showing them early costs no layout shift
+ // (the placeholder is the same height). The loading layout is hidden from
+ // screen readers, hence { hidden: true }.
renderComparison({ compare: undefined, isLoading: true, sourceDetails });
expect(
diff --git a/deployments/stitch-frontend/src/components/Pagination.jsx b/deployments/stitch-frontend/src/components/Pagination.jsx
index 370556c2..9680470a 100644
--- a/deployments/stitch-frontend/src/components/Pagination.jsx
+++ b/deployments/stitch-frontend/src/components/Pagination.jsx
@@ -1,4 +1,5 @@
import { PAGE_SIZE_OPTIONS } from "../queries/resources";
+import Select from "./Select";
// Five-digit page numbers are wide enough that the usual window of nearby
// pages overflows the control, so past this many pages the window narrows to
@@ -81,12 +82,17 @@ export default function Pagination({
totalPages,
onPageChange,
onPageSizeChange,
+ // Arrows around "Page X of Y" instead of page-number buttons, for narrow
+ // containers such as the Merge Review queue.
+ compact = false,
}) {
- const slots = getSlots(
- page,
- totalPages,
- totalPages > WIDE_WINDOW_MAX_PAGES ? NARROW_WINDOW : WIDE_WINDOW,
- );
+ const slots = compact
+ ? []
+ : getSlots(
+ page,
+ totalPages,
+ totalPages > WIDE_WINDOW_MAX_PAGES ? NARROW_WINDOW : WIDE_WINDOW,
+ );
const firstItem = (page - 1) * pageSize + 1;
const lastItem = Math.min(page * pageSize, totalCount);
@@ -108,6 +114,12 @@ export default function Pagination({
‹
+ {compact && (
+
+ Page {page.toLocaleString()} of {totalPages.toLocaleString()}
+
+ )}
+
{slots.map((slot, i) =>
slot.ellipsis ? (
Per page:
-
+