Support reclaiming mannequins to customer-owned bot accounts - #7
Conversation
Adds bot-target support to `gh elm target mannequin reclaim`, so an org admin can reattribute migrated content from a mannequin to a customer-owned GitHub App / bot account (previously only human users were supported). - BotID resolves a [bot] login to its node ID via the REST users endpoint. - ReattributeMannequinToBot calls the reattributeMannequinToBot mutation. - The reclaim service routes [bot]-suffixed targets (case-insensitively) through the bot path; bot reclaims auto-accept and are fail-fast. - Confirmation prompt and advisory warning before irreversible bot reclaims, skippable with --no-prompt. - Adds mannequin_claiming_bot to the GraphQL-Features header.
There was a problem hiding this comment.
🟡 Changes recommended
Case-insensitive bot handling bypasses the TUI’s irreversible-operation warning, and confirmation can understate the number of affected mannequins.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds bot-account targets to mannequin reclamation, including irreversible-operation safeguards and failure handling.
Changes:
- Adds bot reclaim confirmation and warning flows.
- Makes bot detection case-insensitive and failures actionable.
- Updates documentation and tests.
File summaries
| File | Description |
|---|---|
README.md |
Documents bot reclamation. |
internal/ghapi/reclaim.go |
Updates bot detection and failure handling. |
internal/ghapi/reclaim_test.go |
Tests bot failure propagation. |
internal/ghapi/ghapi.go |
Clarifies feature-flag documentation. |
internal/ghapi/ghapi_test.go |
Modernizes API assertions. |
internal/cmd/target/mannequins.go |
Adds bot confirmation prompts. |
internal/cmd/target/mannequins_test.go |
Tests CLI bot workflows. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Quote [bot] logins in the README example so shells don't glob them - Reword the single-target reclaim prompt to reflect that it reattributes every mannequin identity matching the login, not just one - Use ghapi.IsBotLogin in the TUI so an uppercase [BOT] target is treated as the irreversible bot flow, and cover the casing in the TUI confirmation test
There was a problem hiding this comment.
🟡 Changes recommended
Confirmation handling can misclassify normalized bot targets, omit TUI warnings, and reject or double-prompt valid bot reclaims.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
internal/cmd/target/mannequins.go:249
- With a bot target and
--skip-invitation, this prompt runs afterensureSkipInvitationAllowedhas already enforced org-admin membership and consumed a confirmation. Bot reattribution does not use that flag and also permits enterprise admins, so this combination can reject an otherwise authorized admin or unexpectedly prompt twice. Bypass skip-invitation validation for bot-only single reclaims (or reject the incompatible flag explicitly).
if err := confirmBotReclaims(cmd, log, []ghapi.MannequinRecord{{MannequinUser: mannequinUser, TargetUser: targetUser}}, noPrompt); err != nil {
- Files reviewed: 9/9 changed files
- Comments generated: 3
- Review effort level: Balanced
- Only enforce --skip-invitation org-admin eligibility when a non-bot row will use user reattribution, so all-bot batches aren't wrongly rejected or prompted twice (bot reclaims ignore --skip-invitation) - TUI: trim the target before choosing the reclaim path so a bot login with stray whitespace still selects the irreversible confirmation - TUI: classify the actual records (reading CSV rows) so bot reclaims surface the irreversible warning and flag likely mis-targets, matching confirmBotReclaims - Add ghapi.BotReclaimAdvisory shared by the CLI and TUI, with unit and TUI tests
There was a problem hiding this comment.
🟡 Changes recommended
Blank CSV targets are misclassified as user reclaims, causing inaccurate prompts and potentially invalid authorization checks.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 1
- Review effort level: Balanced
BotReclaimAdvisory counted rows with a blank target as user reclaims, but ReclaimMannequins skips those rows (e.g. unedited rows exported by `mannequin list`). That mis-stated the confirmation summary and, with --skip-invitation, ran the org-admin-only preflight for an otherwise bot-only batch. Skip records whose trimmed target is empty before classifying.
isaevt
left a comment
There was a problem hiding this comment.
Left a question about internal/ghapi/reclaim.go
Also, the PR’s “Testing/screenshots” section still says “To be filled in.” It would be useful to record the manual validation performed for this irreversible operation.
| isBot := IsBotLogin(r.TargetUser) | ||
| claimantID, err := s.resolveTargetID(ctx, r.TargetUser, isBot) | ||
| if err != nil { | ||
| if errors.Is(err, ErrUserNotFound) { | ||
| s.log.Warnf("Claimant %q not found. Skipping.", r.TargetUser) | ||
| continue | ||
| } | ||
| // Auth/network/other failures must not be silently skipped. | ||
| return err | ||
| } |
There was a problem hiding this comment.
When a bot target cannot be resolved, could we return an error instead of skipping the row?
With the current continue, a CSV can partially complete: earlier bot rows may be reattributed, a misspelled [bot] target is skipped, and the command still exits successfully. That makes it hard for automation to detect an incomplete reclaim.
There was a problem hiding this comment.
In my opinion, this should fail open. The log is adding the observability aspect and one failure doesn't impact the entire reclaim flow
| // calls made by a mixed CSV reclaim (bot "example-ci[bot]" plus human "alice-t"). | ||
| // The returned bools are set when the bot mutation and the invitation are | ||
| // invoked, respectively. | ||
| func botCSVServer(t *testing.T) (srv *httptest.Server, botCalled, invited *bool) { |
There was a problem hiding this comment.
I think we could simplify this by dispatching GraphQL requests based on operationName rather than strings.Contains on the query. It would make the mock less brittle if the queries change and make it clearer which operations the test supports.
Something along these lines:
type graphqlRequest struct {
OperationName string `json:"operationName"`
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req graphqlRequest
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
switch req.OperationName {
case "GetOrganization":
writeJSON(w, `{"data":{"organization":{"id":"ORG"}}}`)
case "ListMannequins":
writeJSON(w, `...`)
case "GetUser":
writeJSON(w, `{"data":{"user":{"id":"u2"}}}`)
case "ReattributeMannequinToBot":
*botCalled = true
writeJSON(w, `...`)
case "CreateAttributionInvitation":
*invited = true
writeJSON(w, `...`)
default:
require.Failf(t, "unexpected GraphQL operation", "operation: %q", req.OperationName)
}
}))| // reattributeMannequinToBot calls made by a bot reclaim to target | ||
| // "example-ci[bot]". The returned bool is set to true when the bot mutation is | ||
| // invoked. | ||
| func botClaimServer(t *testing.T, mannequinLogin string) (*httptest.Server, *bool) { |
There was a problem hiding this comment.
Similar thought here. Rather than having the handler inspect the query with strings.Contains, I’d consider mapping the expected GQL queries to their responses and having the handler just look up the request query.
For example:
func botClaimServer(t *testing.T, mannequinLogin string) (*httptest.Server, *bool) {
const (
mannequinID = "m1"
botLogin = "example-ci[bot]"
botNodeID = "BOT1"
)
called := new(bool)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/users/") {
_, _ = fmt.Fprintf(w, `{"type":"Bot","node_id":%q}`, botNodeID)
return
}
var req struct {
OperationName string `json:"operationName"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
w.Header().Set("Content-Type", "application/json")
switch req.OperationName {
case "GetOrganization":
_, _ = io.WriteString(w, `{"data":{"organization":{"id":"ORG"}}}`)
case "ListMannequins":
_, _ = fmt.Fprintf(w,
`{"data":{"node":{"mannequins":{"pageInfo":{"endCursor":"","hasNextPage":false},"nodes":[{"id":%q,"login":%q,"claimant":null}]}}}}`,
mannequinID, mannequinLogin,
)
case "ReattributeMannequinToBot":
*called = true
_, _ = fmt.Fprintf(w,
`{"data":{"reattributeMannequinToBot":{"source":{"id":%q,"login":%q},"target":{"id":%q,"login":%q}}}}`,
mannequinID, mannequinLogin, botNodeID, botLogin,
)
default:
require.Failf(t, "unexpected GraphQL operation", "operation: %q", req.OperationName)
}
}))
return srv, called
}I think this is preferable to strings.Contains(req.Query, ...) because the mock doesn't care about the formatting or structure of the query. It also makes it immediately obvious which GraphQL operations the test expects.
| isBot := IsBotLogin(r.TargetUser) | ||
| claimantID, err := s.resolveTargetID(ctx, r.TargetUser, isBot) | ||
| if err != nil { | ||
| if errors.Is(err, ErrUserNotFound) { | ||
| s.log.Warnf("Claimant %q not found. Skipping.", r.TargetUser) | ||
| continue | ||
| } | ||
| // Auth/network/other failures must not be silently skipped. | ||
| return err | ||
| } |
There was a problem hiding this comment.
In my opinion, this should fail open. The log is adding the observability aspect and one failure doesn't impact the entire reclaim flow
Name each GraphQL query/mutation and send operationName so test mocks can switch on the operation instead of matching substrings of the query text, making them resilient to query formatting changes and explicit about which operations they support. Addresses review feedback from @iomekam.
This PR adds bot-target support to
gh elm mannequin claim, so an organization admin or enterprise admin can reattribute migrated content from a bot mannequin to a customer-owned GitHub App / bot account (previously only human users were supported). Attribution to GitHub owned/first party apps is rejected on the backend.Context
Customers whose automation moved from machine-user "bot accounts" to GitHub Apps couldn't reclaim their apps' bot-authored content. The reclaim path resolves the target via the GraphQL user(login:) query, which hides bots (it only resolves accounts where user? is true), so an app login like example-ci[bot] could not be resolved or reclaimed.
Notable Changes
[bot]suffix on the target and routes to a newReattributeMannequinToBotmutation.mannequin_claiming_botfeature flag in theGraphQL-Featuresheader--no-promptflag to skip the prompt step[bot]suffix, a warning is displayed. We allow this because non GitHub sources don't use GitHub bot conventions and we still want customers to be able to reclaim ADO/BBS bot accountsTesting/screenshots
Reclaiming against target customer owned bot
Non-blocking advisory warning when source mannequin doesn't end in [bot]
Reclaim fails against bot owned by another org