feat(user): list all users and expose identity fields for merge triage - #327
Conversation
Consolidating duplicate accounts needed two things the CLI could not do:
see the whole user population, and see which credential each account
actually authenticates with.
`user list` (aliases: search, ls) now takes an OPTIONAL query. Omitted, it
requests the server's unfiltered listing — the one a platform ADMIN/OWNER
may run. `$query` became a nullable String with omitempty so a nil pointer
drops the variable entirely, which GraphQL coerces to `filter: {}`; sending
an empty string would instead be a blank filter matching nothing. A caller
without platform authority gets an empty list rather than an error, so the
human branch explains why instead of rendering a bare table.
Without an explicit --limit/--offset the listing pages to exhaustion via
api.CollectAll, per the whole-corpus rule — a population larger than one
200-row server page must not be silently truncated when the whole point is
finding duplicates.
UserFields gained identityProvider, githubId, externalId, externalAppId and
linkedAt, and PROVIDER joins the table. These predict what `user merge`
does to a login: mergeUsers adopts a source provider id only where the
target's is null, and email is target-wins with the source's nulled, so
whether a credential survives is knowable before an irreversible mutation
with no dry-run. agentic-usage.md now says so next to `user merge`, along
with the fact that the target's handle always wins and there is no admin
handle rename.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 89383ddde5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| **The identity fields go on the shared `UserFields` fragment**, so `org member | ||
| list` and `access check` surface them too. They are the fields that distinguish | ||
| "two rows for one human" from "two humans", and that judgement is made wherever | ||
| users are listed, not only in `user list`. |
There was a problem hiding this comment.
Surface identity fields in the advertised command outputs
The design-as-built claim is not implemented: org member list --json still marshals the explicit DTO in internal/cmd/org/common.go:31-39,63-75, which contains only the old profile fields, while access check discards the resolved UserFields after obtaining the ID and never outputs a user. Adding fields to the GraphQL fragment merely fetches them; it does not surface them through either command. Extend those command DTOs/output paths or narrow this advertised behavior. CLAUDE.mdL58-L61
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Adds an admin-capable “list all users” mode and exposes identity-related fields so operators can triage duplicate accounts and predict user merge outcomes more accurately, while preserving the CLI’s stable --json contract.
Changes:
- Makes
hadron user searchaccept an optional[query](aliasesls,list), where omitting the query requests the unfiltered admin listing and defaults to paging-to-exhaustion. - Extends the shared
UserFieldsGraphQL fragment + CLI JSON DTO with identity fields (identityProvider,githubId,externalId,externalAppId,linkedAt). - Updates tests, agent-facing usage docs, and adds a design-as-built plan document for the new behavior.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/cmdutil/userref.go | Updates user-ref resolution to always pass a concrete search query after $query became nullable. |
| internal/cmd/user/user.go | Implements optional query semantics, paging-to-exhaustion default, table output updates, and DTO additions for identity fields. |
| internal/cmd/user_cmd_test.go | Adds coverage for query omission, paging behavior, identity fields in JSON output, and empty full-list hinting. |
| internal/cmd/list_naming_test.go | Ensures user ls and user list alias pairing resolves consistently. |
| internal/cmd/agentic/agentic-usage.md | Updates command synopsis and documents merge-relevant identity/credential considerations. |
| internal/api/queries/org.graphql | Makes $query nullable + omitempty and expands UserFields fragment with identity fields. |
| internal/api/gen/generated.go | Regenerates genqlient output to reflect the updated fragment and nullable $query. |
| docs/plans/user-list-identity-fields.md | Adds the design-as-built plan documenting motivations, decisions, and tests. |
Files not reviewed (1)
- internal/api/gen/generated.go: Generated file
Comments suppressed due to low confidence (1)
internal/cmd/user_cmd_test.go:134
- searchPagesServer ignores JSON decode errors for the request body. If decoding fails, the handler will treat the request as offset=0 and could make paging assertions pass/fail for the wrong reason.
Variables struct {
Offset *int `json:"offset"`
} `json:"variables"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var vars map[string]any | ||
| _ = json.Unmarshal(captured["SearchUsers"], &vars) |
| // PROVIDER earns a column because it is the field that decides | ||
| // whether merging two accounts preserves or destroys a login. |
Codex was right that the plan doc claimed more than the code did: adding fields to the shared UserFields fragment only makes them available, since every command marshals its own DTO to keep --json stable. `org member list` has its own userDTO and was dropping them on the floor — extended it to match, with a test, because a member roster is where two rows for the same human are most visible. `access check` selects UserFields only to resolve an id and emits no user object, so the doc no longer claims it surfaces anything. Copilot correctly caught a stale comment on the PROVIDER column asserting identityProvider "decides whether merging preserves or destroys a login" — that is the misreading this PR exists to correct. It explains an account's origin; auth resolves by provider id then email. Also tightened two tests that swallowed a decode error: the full-list assertion is a negative one and would have passed vacuously on an empty map, and a decode failure in the paging fake would have looked like offset=0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Addressed the review in 3a0a5f4. @chatgpt-codex-connector — identity fields not actually surfaced (P2): fixed, good catch. The plan doc claimed the shared @copilot — misleading PROVIDER comment: fixed. The comment asserted @copilot — swallowed decode errors in tests: fixed. Worth doing specifically because the full-list assertion is a negative one ( CI green. |
|
To use Codex here, create an environment for this repo. |
Why
Duplicate accounts arise from ordinary behaviour — signing in once with GitHub and once with Google/Apple/email mints two Users. Consolidating them with
user mergeneeded two things the CLI could not do.See the whole population. The server already lets a platform
ADMIN/OWNERomitfilter.queryfor the full list (cor:acl:070:02), butSearchUsersdeclared$query: String!and the command rejected an empty one. The only available technique was guessing substrings and unioning the results — which finds the accounts you thought to search for, not the ones you didn't. An email signup produces an auto-generated hex handle and no GitHub username, so it is invisible to substring search unless you already know its address.See which credential each account authenticates with.
UserFieldsselectedemailandgithubUsernameonly — not enough to predict what a merge does to a login, andmergeUsersis irreversible with no dry-run.What changed
user list [query](aliasessearch,ls) — the query is now optional; omitted, it requests the unfiltered admin listing.$queryis a nullableStringwithomitempty, so a nil pointer drops the variable and GraphQL coerces it tofilter: {}. An empty string would instead be a blank filter matching nothing — a distinction worth being exact about, since the failure mode looks like a permissions problem. An explicitly empty argument is still a usage error.--limit/--offsetthe listing pages to exhaustion viaapi.CollectAll, per the whole-corpus rule in CLAUDE.md.UserFieldsgainedidentityProvider,githubId,externalId,externalAppId,linkedAt;PROVIDERjoins the table.The subtlety worth reviewing
identityProviderlooks like the login method and isn't. Auth resolves a user by provider id first (googleId/githubId/appleId), then by verified email;identityProvideronly records which provider created the row, and the linking branch deliberately keeps the existing value.mergeUsersmirrors this — it adopts a source provider id wherever the target's is null — so a merge usually preserves both sign-in paths. The field that actually gets destroyed isemail: target-wins, with the source's nulled on the tombstone.agentic-usage.mdnow states this next touser merge, together with the fact that the target's handle always wins and there is no admin handle rename — so the merge direction is final.Validation
Used to consolidate six duplicate accounts in production (22 users → 16), predicting each outcome in advance and matching it exactly: Duygu's account adopted the GitHub id alongside its Google identity, Raghvind's adopted the Apple id, Rashmi's adopted the email, and Eash's three rows collapsed to one retaining GitHub plus
eash@micromentor.org. All org roles and memberships held.Tests
Query variable omitted (not empty) for the full list; identity fields round-trip through
--json; unbounded listing drains multiple pages and stops attotal; explicit--limitmakes exactly one request; empty full list explains itself. Plus theuser ls/user listalias pair inlist_naming_test.go.Design-as-built:
docs/plans/user-list-identity-fields.md.Follow-up
googleId/appleIdexist on the server's User but aren't on the GraphQL type, so those provider ids remain invisible to the CLI and have to be inferred fromidentityProvider. That's a hadron-server change, worth doing before the next consolidation.🤖 Generated with Claude Code