Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@ gh elm target mannequin reclaim octo-org --csv mannequins.csv

# Immediate reattribution (EMU orgs only); prompts unless --no-prompt
gh elm target mannequin reclaim octo-org --csv mannequins.csv --skip-invitation

# Reclaim to a customer-owned GitHub App / bot account (target login ends in [bot]);
# irreversible, so it prompts unless --no-prompt
gh elm target mannequin reclaim octo-org 'legacy-ci[bot]' 'example-ci[bot]'
```

## Configuration
Expand Down
65 changes: 55 additions & 10 deletions internal/cmd/target/mannequins.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,30 +220,41 @@ func newMannequinReclaimCmd() *cobra.Command {
log := mannequinLogger{w: cmd.ErrOrStderr()}
svc := ghapi.NewReclaimService(client, log)

if skipInvitation {
if err := ensureSkipInvitationAllowed(cmd, client, githubOrg, noPrompt); err != nil {
return annotateMannequinAuthError(err, targetURLResolved)
}
}

var records []ghapi.MannequinRecord
if csvPath != "" {
log.Infof("Reclaiming mannequins from CSV...")
f, err := os.Open(csvPath)
if err != nil {
return fmt.Errorf("opening %s: %w", csvPath, err)
}
defer func() { _ = f.Close() }()
records, err := ghapi.ReadMannequinCSV(f)
records, err = ghapi.ReadMannequinCSV(f)
if err != nil {
return err
}
} else {
log.Infof("Reclaiming mannequin...")
records = []ghapi.MannequinRecord{{MannequinUser: mannequinUser, TargetUser: targetUser}}
}

if skipInvitation {
if _, userCount, _ := ghapi.BotReclaimAdvisory(records); userCount > 0 {
if err := ensureSkipInvitationAllowed(cmd, client, githubOrg, noPrompt); err != nil {
return annotateMannequinAuthError(err, targetURLResolved)
}
}
}

if err := confirmBotReclaims(cmd, log, records, noPrompt); err != nil {
return err
}

if csvPath != "" {
if err := svc.ReclaimMannequins(cmd.Context(), records, githubOrg, force, skipInvitation); err != nil {
return annotateMannequinAuthError(err, targetURLResolved)
}
return nil
}

log.Infof("Reclaiming mannequin...")
if err := svc.ReclaimMannequin(cmd.Context(), mannequinUser, mannequinID, targetUser, githubOrg, force, skipInvitation); err != nil {
return annotateMannequinAuthError(err, targetURLResolved)
}
Expand All @@ -259,7 +270,7 @@ func newMannequinReclaimCmd() *cobra.Command {
cmd.Flags().StringVar(&targetUser, "target-user", "", "Target user login (alternative to the positional argument).")
cmd.Flags().BoolVar(&force, "force", false, "Reclaim even if the mannequin is already mapped to a user.")
cmd.Flags().BoolVar(&skipInvitation, "skip-invitation", false, "Reattribute immediately without the invitation email (EMU orgs only).")
cmd.Flags().BoolVar(&noPrompt, "no-prompt", false, "Do not prompt for confirmation when using --skip-invitation.")
cmd.Flags().BoolVar(&noPrompt, "no-prompt", false, "Do not prompt for confirmation (--skip-invitation or bot reclaims).")
cmd.Flags().StringVar(&targetURL, "target-url", "", "Override the target API base URL.")
cmd.Flags().StringVar(&targetToken, "target-token", "", "Override the target API token.")
_ = cmd.Flags().MarkHidden("github-org")
Expand Down Expand Up @@ -312,6 +323,40 @@ func confirm(in io.Reader, out io.Writer, prompt string) bool {
}
}

// confirmBotReclaims warns about likely mis-targets and, unless noPrompt is set,
// asks for confirmation before an irreversible bot reattribution. Reattributing
// to a bot auto-accepts and cannot be undone. The source mannequin's login is
// our only hint that it represents a bot; a non-"[bot]" source is very likely a
// mis-target (a human's content going to a bot), but the convention is
// GitHub-specific, so we warn and let the admin proceed rather than blocking.
func confirmBotReclaims(cmd *cobra.Command, log mannequinLogger, records []ghapi.MannequinRecord, noPrompt bool) error {
botCount, humanCount, mistargets := ghapi.BotReclaimAdvisory(records)
for _, src := range mistargets {
log.Warnf("%q does not look like a bot mannequin (its login does not end in %q). Are you sure you want to do this?", src, "[bot]")
}

if botCount == 0 || noPrompt {
return nil
}

var summary string
if len(records) > 1 {
summary = fmt.Sprintf("You are about to reattribute %d mannequin(s) to GitHub App / bot account(s)", botCount)
if humanCount > 0 {
summary += fmt.Sprintf(" and %d mannequin(s) to user(s)", humanCount)
}
summary += "."
} else {
summary = fmt.Sprintf("You are about to reattribute every mannequin identity matching %q to the GitHub App / bot account %q.", records[0].MannequinUser, records[0].TargetUser)
}

if !confirm(cmd.InOrStdin(), cmd.ErrOrStderr(),
summary+" Reattributing content to a bot is immediate and cannot be undone. Continue? [y/N]") {
return errors.New("aborted")
}
return nil
}

// mannequinClient resolves the target endpoint (flag > env > stored config) and
// returns a ready GitHub API client plus the resolved base URL for error
// messages. It mirrors targetClient but returns a *ghapi.Client for the
Expand Down
138 changes: 137 additions & 1 deletion internal/cmd/target/mannequins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package target
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -185,7 +186,6 @@ func TestMannequinReclaim(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "aborted")
})

t.Run("accepts the legacy claim command and flags", func(t *testing.T) {
_, err := runMannequin(t, newMannequinClaimCmd, "",
"--github-org", "octo", "--target-url", "https://x", "--target-token", "tok")
Expand Down Expand Up @@ -214,4 +214,140 @@ func TestMannequinReclaim(t *testing.T) {
assert.Contains(t, err.Error(), "no target URL configured")
assert.NotContains(t, err.Error(), "duplicates")
})

t.Run("reclaims to a bot after confirmation", func(t *testing.T) {
srv, called := botClaimServer(t, "legacy-ci[bot]")
defer srv.Close()

out, err := runMannequin(t, newMannequinReclaimCmd, "y\n",
"octo", "legacy-ci[bot]", "example-ci[bot]",
"--target-url", srv.URL, "--target-token", "tok")
require.NoErrorf(t, err, "claim output:\n%s", out)
assert.True(t, *called, "expected reattributeMannequinToBot to be called")
})

t.Run("aborts a bot reclaim when the admin declines the prompt", func(t *testing.T) {
srv, called := botClaimServer(t, "legacy-ci[bot]")
defer srv.Close()

_, err := runMannequin(t, newMannequinReclaimCmd, "n\n",
"octo", "legacy-ci[bot]", "example-ci[bot]",
"--target-url", srv.URL, "--target-token", "tok")
require.Error(t, err)
assert.Contains(t, err.Error(), "aborted")
assert.False(t, *called, "no reclaim should occur after declining")
})

t.Run("bot reclaim with --no-prompt proceeds without confirmation", func(t *testing.T) {
srv, called := botClaimServer(t, "legacy-ci[bot]")
defer srv.Close()

out, err := runMannequin(t, newMannequinReclaimCmd, "",
"octo", "legacy-ci[bot]", "example-ci[bot]",
"--no-prompt", "--target-url", srv.URL, "--target-token", "tok")
require.NoErrorf(t, err, "claim output:\n%s", out)
assert.True(t, *called, "expected reattributeMannequinToBot to be called")
})

t.Run("warns when the source mannequin does not look like a bot", func(t *testing.T) {
srv, called := botClaimServer(t, "alice")
defer srv.Close()

out, err := runMannequin(t, newMannequinReclaimCmd, "y\n",
"octo", "alice", "example-ci[bot]",
"--target-url", srv.URL, "--target-token", "tok")
require.NoErrorf(t, err, "claim output:\n%s", out)
assert.True(t, *called, "expected reattributeMannequinToBot to be called")
assert.Contains(t, out, "does not look like a bot mannequin", "expected soft-signal advisory warning")
})

t.Run("reclaims a bot row from a CSV with mixed targets", func(t *testing.T) {
srv, botCalled, invited := botCSVServer(t)
defer srv.Close()

path := filepath.Join(t.TempDir(), "mannequins.csv")
csv := "mannequin-user,mannequin-id,target-user\n" +
"legacy-ci[bot],m1,example-ci[bot]\n" +
"alice,m2,alice-t\n"
require.NoError(t, os.WriteFile(path, []byte(csv), 0o600))

out, err := runMannequin(t, newMannequinReclaimCmd, "y\n",
"octo", "--csv", path,
"--target-url", srv.URL, "--target-token", "tok")
require.NoErrorf(t, err, "claim output:\n%s", out)
assert.True(t, *botCalled, "expected reattributeMannequinToBot to be called for the bot row")
assert.True(t, *invited, "expected createAttributionInvitation to be called for the human row")
})
}

// botCSVServer answers the org id, all-mannequins listing, REST bot lookup, user
// lookup, and both the reattributeMannequinToBot and createAttributionInvitation
// 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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
	}
}))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good suggestion. Fixed this in dbea179

botCalled = new(bool)
invited = new(bool)
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/users/") {
_, _ = io.WriteString(w, `{"type":"Bot","node_id":"BOT1"}`)
return
}
var req struct {
OperationName string `json:"operationName"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
switch req.OperationName {
case "GetOrganization":
_, _ = io.WriteString(w, `{"data":{"organization":{"id":"ORG"}}}`)
case "ListMannequins":
_, _ = io.WriteString(w, `{"data":{"node":{"mannequins":{"pageInfo":{"endCursor":"","hasNextPage":false},"nodes":[{"id":"m1","login":"legacy-ci[bot]","claimant":null},{"id":"m2","login":"alice","claimant":null}]}}}}`)
case "GetUser":
_, _ = io.WriteString(w, `{"data":{"user":{"id":"u2"}}}`)
case "ReattributeMannequinToBot":
*botCalled = true
_, _ = io.WriteString(w, `{"data":{"reattributeMannequinToBot":{"source":{"id":"m1","login":"legacy-ci[bot]"},"target":{"id":"BOT1","login":"example-ci[bot]"}}}}`)
case "CreateAttributionInvitation":
*invited = true
_, _ = io.WriteString(w, `{"data":{"createAttributionInvitation":{"source":{"id":"m2","login":"alice"},"target":{"id":"u2","login":"alice-t"}}}}`)
default:
require.Failf(t, "unexpected GraphQL operation", "operation: %q", req.OperationName)
}
}))
return srv, botCalled, invited
}

// botClaimServer answers the org id, mannequins-by-login, REST bot lookup, and
// 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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed this in dbea179

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))
switch req.OperationName {
case "GetOrganization":
_, _ = io.WriteString(w, `{"data":{"organization":{"id":"ORG"}}}`)
case "ListMannequinsByLogin":
_, _ = 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
}
30 changes: 22 additions & 8 deletions internal/ghapi/ghapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"slices"
"strings"
"time"
Expand All @@ -24,11 +25,10 @@ const (
apiVersionHeader = "X-GitHub-Api-Version"
apiVersion = "2022-11-28"

// graphQLFeaturesHeader opts into preview GraphQL schema features. The
// mannequin_claiming_emu feature exposes the reattributeMannequinToUser
// mutation used by `mannequin reclaim --skip-invitation`; without it the
// mutation; mannequin_claiming_bot
// exposes reattributeMannequinToBot used when reclaiming to a GitHub App / bot
// graphQLFeaturesHeader opts into preview GraphQL schema features.
// mannequin_claiming_emu exposes the reattributeMannequinToUser mutation used
// by `mannequin reclaim --skip-invitation`; mannequin_claiming_bot exposes the
// reattributeMannequinToBot mutation used when reclaiming to a GitHub App / bot
// account. Without them the mutations are absent from the schema and the call
// fails with "doesn't exist on type 'Mutation'". gh-gei sends this header on
// every request (it is ignored by REST), so we do the same.
Expand Down Expand Up @@ -102,8 +102,22 @@ func (e *GraphQLError) Error() string {

// graphQLRequest is the JSON body of a GraphQL POST.
type graphQLRequest struct {
Query string `json:"query"`
Variables any `json:"variables,omitempty"`
Query string `json:"query"`
OperationName string `json:"operationName,omitempty"`
Variables any `json:"variables,omitempty"`
}

// graphQLOperationNameRE captures the name of a named query or mutation.
var graphQLOperationNameRE = regexp.MustCompile(`(?m)^\s*(?:query|mutation)\s+([A-Za-z_]\w*)`)

// graphQLOperationName returns the operation name of a named query or mutation,
// or "" for an anonymous operation. Sending operationName lets servers (and test
// mocks) dispatch on the operation rather than the query text.
func graphQLOperationName(query string) string {
if m := graphQLOperationNameRE.FindStringSubmatch(query); m != nil {
return m[1]
}
return ""
}

// graphQLResponse captures the parts of a GraphQL response gh-elm inspects.
Expand All @@ -117,7 +131,7 @@ type graphQLResponse struct {
// graphQL issues a GraphQL query/mutation and decodes response.data into out.
// A non-empty errors array is returned as a *GraphQLError.
func (c *Client) graphQL(ctx context.Context, query string, variables, out any) error {
body, err := json.Marshal(graphQLRequest{Query: query, Variables: variables})
body, err := json.Marshal(graphQLRequest{Query: query, OperationName: graphQLOperationName(query), Variables: variables})
if err != nil {
return fmt.Errorf("encoding graphql request: %w", err)
}
Expand Down
Loading