Skip to content

feat(user): list all users and expose identity fields for merge triage - #327

Merged
shadowbrush merged 2 commits into
mainfrom
feat/user-list-identity-fields
Jul 30, 2026
Merged

feat(user): list all users and expose identity fields for merge triage#327
shadowbrush merged 2 commits into
mainfrom
feat/user-list-identity-fields

Conversation

@shadowbrush

Copy link
Copy Markdown
Member

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 merge needed two things the CLI could not do.

See the whole population. The server already lets a platform ADMIN/OWNER omit filter.query for the full list (cor:acl:070:02), but SearchUsers declared $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. UserFields selected email and githubUsername only — not enough to predict what a merge does to a login, and mergeUsers is irreversible with no dry-run.

What changed

  • user list [query] (aliases search, ls) — the query is now optional; omitted, it requests the unfiltered admin listing.
  • $query is a nullable String with omitempty, so a nil pointer drops the variable and GraphQL coerces it to filter: {}. 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.
  • Without an explicit --limit/--offset the listing pages to exhaustion via api.CollectAll, per the whole-corpus rule in CLAUDE.md.
  • A non-admin gets an empty list rather than an error (the server won't confirm that accounts it won't show you exist); the human branch prints a hint so that isn't misread as "no users".
  • UserFields gained identityProvider, githubId, externalId, externalAppId, linkedAt; PROVIDER joins the table.

The subtlety worth reviewing

identityProvider looks like the login method and isn't. Auth resolves a user by provider id first (googleId/githubId/appleId), then by verified email; identityProvider only records which provider created the row, and the linking branch deliberately keeps the existing value. mergeUsers mirrors 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 is email: target-wins, with the source's nulled on the tombstone.

agentic-usage.md now states this next to user 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 at total; explicit --limit makes exactly one request; empty full list explains itself. Plus the user ls/user list alias pair in list_naming_test.go.

Design-as-built: docs/plans/user-list-identity-fields.md.

Follow-up

googleId/appleId exist 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 from identityProvider. That's a hadron-server change, worth doing before the next consolidation.

🤖 Generated with Claude Code

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>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread docs/plans/user-list-identity-fields.md Outdated
Comment on lines +53 to +56
**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`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 search accept an optional [query] (aliases ls, list), where omitting the query requests the unfiltered admin listing and defaults to paging-to-exhaustion.
  • Extends the shared UserFields GraphQL 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.

Comment thread internal/cmd/user_cmd_test.go Outdated
Comment on lines +100 to +101
var vars map[string]any
_ = json.Unmarshal(captured["SearchUsers"], &vars)
Comment thread internal/cmd/user/user.go Outdated
Comment on lines +242 to +243
// 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>
@shadowbrush

Copy link
Copy Markdown
Member Author

Addressed the review in 3a0a5f4.

@chatgpt-codex-connector — identity fields not actually surfaced (P2): fixed, good catch. The plan doc claimed the shared UserFields fragment made org member list and access check surface the new fields, which was wrong on both counts. Every command marshals its own DTO to keep --json stable across regenerations, so the fragment only makes the data available. org member list had its own userDTO that dropped them — extended it to match, with a test, since a member roster is where two rows for one human are most visible. access check selects UserFields only to resolve an id and emits no user object, so the doc no longer claims anything for it.

@copilot — misleading PROVIDER comment: fixed. The comment asserted identityProvider "decides whether merging preserves or destroys a login", which is precisely the misreading this PR exists to correct — a stale first-draft line I should have caught. It now says the column explains an account's origin, and points at the fields that actually decide the merge outcome.

@copilot — swallowed decode errors in tests: fixed. Worth doing specifically because the full-list assertion is a negative one (query key absent) and would have passed vacuously on an empty map; it now fails if nothing was captured. The paging fake reports a decode failure instead of silently reading as offset=0.

CI green.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@shadowbrush
shadowbrush merged commit 4cdb7d4 into main Jul 30, 2026
4 checks passed
@shadowbrush
shadowbrush deleted the feat/user-list-identity-fields branch July 30, 2026 15:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants