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
165 changes: 165 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# AGENTS.md

This is the GitHub CLI (`gh`), a command-line tool for interacting with GitHub. The module path is `github.com/cli/cli/v2`.

## Build, Test, and Lint

```bash
make # Build (Unix) — outputs bin/gh
go run script/build.go # Build (Windows)
go test ./... # All unit tests
go test ./pkg/cmd/issue/list/... -run TestIssueList_nontty # Single test
go test -tags acceptance ./acceptance # Acceptance tests
make lint # golangci-lint (same as CI)
```

## Architecture

Entry point: `cmd/gh/main.go` → `internal/ghcmd.Main()` → `pkg/cmd/root.NewCmdRoot()`.

Key packages:
- `pkg/cmd/<command>/<subcommand>/` — CLI command implementations
- `pkg/cmdutil/` — Factory, error types, flag helpers (`NilStringFlag`, `NilBoolFlag`, `StringEnumFlag`)
- `pkg/iostreams/` — I/O abstraction with TTY detection, color, pager
- `pkg/httpmock/` — HTTP mocking for tests
- `api/` — GitHub API client (GraphQL + REST)
- `internal/featuredetection/` — GitHub.com vs GHES capability detection
- `internal/tableprinter/` — Table output for list commands

## Command Structure

A command `gh foo bar` lives in `pkg/cmd/foo/bar/` with `bar.go`, `bar_test.go`, and optionally `http.go`/`http_test.go`.

### Canonical Examples

- **Command + tests**: `pkg/cmd/issue/list/list.go` and `list_test.go`
- **Factory wiring**: `pkg/cmd/factory/default.go`
- **Unit tests**: `internal/agents/detect_test.go`

### The Options + Factory Pattern

Every command follows this structure (see `pkg/cmd/issue/list/list.go`):

1. `Options` struct with `IO`, `HttpClient`, `Config`, `BaseRepo` + flags
2. `NewCmdFoo(f *cmdutil.Factory, runF func(*FooOptions) error)` constructor — `runF` is the test injection point
3. Separate `fooRun(opts)` function with the business logic

Key rules:
- Lazy-init `BaseRepo`, `Remotes`, `Branch` inside `RunE`, not the constructor
- Commands register in `pkg/cmd/root/root.go`; subcommand groups use `cmdutil.AddGroup()`

### Command Examples and Help Text

Use `heredoc.Doc` for examples with `#` comment lines and `$ ` command prefixes:
```go
Example: heredoc.Doc(`
# Do the thing
$ gh foo bar --flag value
`),
```

### JSON Output

Add `--json`, `--jq`, `--template` flags via `cmdutil.AddJSONFlags(cmd, &opts.Exporter, fieldNames)`. In the run function: `if opts.Exporter != nil { return opts.Exporter.Write(opts.IO, data) }`. See `pkg/cmd/pr/list/list.go`.

## Testing

### HTTP Mocking

Use `httpmock.Registry` with `defer reg.Verify(t)` to ensure all stubs are called:

```go
reg := &httpmock.Registry{}
defer reg.Verify(t)

reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO"),
httpmock.JSONResponse(someData),
)
reg.Register(
httpmock.GraphQL(`query PullRequestList\b`),
httpmock.FileResponse("./fixtures/prList.json"),
)
client := &http.Client{Transport: reg}
```

Common: `REST(method, path)`, `GraphQL(pattern)`, `JSONResponse(body)`, `FileResponse(path)`. See `pkg/httpmock/` for all matchers/responders.

### IOStreams in Tests

```go
ios, stdin, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(true) // simulate terminal
```

### Assertions

Use `testify`. Always use `require` (not `assert`) for error checks so the test halts immediately:

```go
require.NoError(t, err)
require.Error(t, err)
assert.Equal(t, "expected", actual)
```

### Generated Mocks

Interfaces use `moq`: `//go:generate moq -rm -out prompter_mock.go . Prompter`. Run `go generate ./...` after interface changes.

### Table-Driven Tests

Use table-driven tests for functions with multiple input/output scenarios. See `internal/agents/detect_test.go` or `pkg/cmd/issue/list/list_test.go` for examples:

```go
tests := []struct {
name string
// inputs and expected outputs
}{
{name: "descriptive case name", ...},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// arrange, act, assert
})
}
```

## Code Style

- Add godoc comments to all exported functions, types, and constants
- Avoid unnecessary code comments — only comment when the *why* isn't obvious from the code
- Do not comment just to restate what the code does

## Error Handling

Error types in `pkg/cmdutil/errors.go`:
- `FlagErrorf(...)` — flag validation (prints usage)
- `cmdutil.SilentError` — exit 1, no message
- `cmdutil.CancelError` — user cancelled
- `cmdutil.PendingError` — outcome pending
- `cmdutil.NoResultsError` — empty results

Use `cmdutil.MutuallyExclusive("message", cond1, cond2)` for mutually exclusive flags.

## Feature Detection

Commands using feature detection must include a `// TODO <cleanupIdentifier>` comment directly above the if-statement for linter compliance:

```go
// TODO someFeatureCleanup
if features.SomeCapability {
// use new API
} else {
// fallback for older GHES
}
```

## API Patterns

```go
client := api.NewClientFromHTTP(httpClient)
client.GraphQL(hostname, query, variables, &data)
client.REST(hostname, "GET", "repos/owner/repo", nil, &data)
```

For host resolution, use `cfg.Authentication().DefaultHost()` — not `ghinstance.Default()` which always returns `github.com`.
1 change: 0 additions & 1 deletion api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
)

const (
accept = "Accept"
apiVersion = "X-GitHub-Api-Version"
apiVersionValue = "2022-11-28"
authorization = "Authorization"
Expand Down
8 changes: 7 additions & 1 deletion api/http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type tokenGetter interface {

type HTTPClientOptions struct {
AppVersion string
InvokingAgent string
CacheTTL time.Duration
Config tokenGetter
EnableCache bool
Expand Down Expand Up @@ -48,8 +49,13 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
clientOpts.LogVerboseHTTP = opts.LogVerboseHTTP
}

ua := fmt.Sprintf("GitHub CLI %s", opts.AppVersion)
if opts.InvokingAgent != "" {
ua = fmt.Sprintf("%s Agent/%s", ua, opts.InvokingAgent)
}

headers := map[string]string{
userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion),
userAgent: ua,
apiVersion: apiVersionValue,
}
clientOpts.Headers = headers
Expand Down
14 changes: 14 additions & 0 deletions api/http_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func TestNewHTTPClient(t *testing.T) {
type args struct {
config tokenGetter
appVersion string
invokingAgent string
logVerboseHTTP bool
skipDefaultHeaders bool
}
Expand Down Expand Up @@ -155,6 +156,18 @@ func TestNewHTTPClient(t *testing.T) {
* Request took <duration>
`),
},
{
name: "includes invoking agent in user-agent header",
args: args{
appVersion: "v1.2.3",
invokingAgent: "copilot-cli",
},
host: "github.com",
wantHeader: map[string][]string{
"user-agent": {"GitHub CLI v1.2.3 Agent/copilot-cli"},
},
wantStderr: "",
},
}

var gotReq *http.Request
Expand All @@ -169,6 +182,7 @@ func TestNewHTTPClient(t *testing.T) {
ios, _, _, stderr := iostreams.Test()
client, err := NewHTTPClient(HTTPClientOptions{
AppVersion: tt.args.appVersion,
InvokingAgent: tt.args.invokingAgent,
Config: tt.args.config,
Log: ios.ErrOut,
LogVerboseHTTP: tt.args.logVerboseHTTP,
Expand Down
11 changes: 10 additions & 1 deletion api/queries_issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,8 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{}
switch key {
case "assigneeIds", "body", "issueTemplate", "labelIds", "milestoneId", "projectIds", "repositoryId", "title":
inputParams[key] = val
case "projectV2Ids":
case "projectV2Ids", "assigneeLogins":
// handled after issue creation
default:
return nil, fmt.Errorf("invalid IssueCreate mutation parameter %s", key)
}
Expand All @@ -310,6 +311,14 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{}
}
issue := &result.CreateIssue.Issue

// Assign users using login-based mutation when ActorAssignees is true (github.com).
if assigneeLogins, ok := params["assigneeLogins"].([]string); ok && len(assigneeLogins) > 0 {
err := ReplaceActorsForAssignableByLogin(client, repo, issue.ID, assigneeLogins)
if err != nil {
return issue, err
}
}

// projectV2 parameters aren't supported in the `createIssue` mutation,
// so add them after the issue has been created.
projectV2Ids, ok := params["projectV2Ids"].([]string)
Expand Down
37 changes: 37 additions & 0 deletions api/queries_pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,14 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
}
}

// Assign users using login-based mutation when ActorAssignees is true (github.com).
if assigneeLogins, ok := params["assigneeLogins"].([]string); ok && len(assigneeLogins) > 0 {
err := ReplaceActorsForAssignableByLogin(client, repo, pr.ID, assigneeLogins)
if err != nil {
return pr, err
}
}

// TODO requestReviewsByLoginCleanup
// Request reviewers using either login-based (github.com) or ID-based (GHES) mutation.
// The ID-based path can be removed once GHES supports requestReviewsByLogin.
Expand Down Expand Up @@ -581,6 +589,35 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
return pr, nil
}

// ReplaceActorsForAssignableByLogin calls the replaceActorsForAssignable mutation
// using actor logins. This avoids the need to resolve logins to node IDs.
func ReplaceActorsForAssignableByLogin(client *Client, repo ghrepo.Interface, assignableID string, logins []string) error {
type ReplaceActorsForAssignableInput struct {
AssignableID githubv4.ID `json:"assignableId"`
ActorLogins []githubv4.String `json:"actorLogins"`
}

actorLogins := make([]githubv4.String, len(logins))
for i, l := range logins {
actorLogins[i] = githubv4.String(l)
}

var mutation struct {
ReplaceActorsForAssignable struct {
TypeName string `graphql:"__typename"`
} `graphql:"replaceActorsForAssignable(input: $input)"`
}

variables := map[string]interface{}{
"input": ReplaceActorsForAssignableInput{
AssignableID: githubv4.ID(assignableID),
ActorLogins: actorLogins,
},
}

return client.Mutate(repo.RepoHost(), "ReplaceActorsForAssignable", &mutation, variables)
}

// SuggestedAssignableActors fetches up to 10 suggested actors for a specific assignable
// (Issue or PullRequest) node ID. `assignableID` is the GraphQL node ID for the Issue/PR.
// Returns the actors, the total count of available assignees in the repo, and an error.
Expand Down
63 changes: 63 additions & 0 deletions api/queries_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -1298,6 +1298,69 @@ func RepoAssignableActors(client *Client, repo ghrepo.Interface) ([]AssignableAc
return actors, nil
}

// SearchRepoAssignableActors searches assignable actors for a repository with an optional
// query string. Unlike RepoAssignableActors which fetches all actors with pagination, this
// returns up to 10 results matching the query, suitable for search-based selection.
func SearchRepoAssignableActors(client *Client, repo ghrepo.Interface, query string) ([]AssignableActor, int, error) {
type responseData struct {
Repository struct {
AssignableUsers struct {
TotalCount int
}
SuggestedActors struct {
Nodes []struct {
User struct {
ID string
Login string
Name string
TypeName string `graphql:"__typename"`
} `graphql:"... on User"`
Bot struct {
ID string
Login string
TypeName string `graphql:"__typename"`
} `graphql:"... on Bot"`
}
} `graphql:"suggestedActors(first: 10, query: $query, capabilities: CAN_BE_ASSIGNED)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}

var q *githubv4.String
if query != "" {
v := githubv4.String(query)
q = &v
}

variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
"query": q,
}

var result responseData
if err := client.Query(repo.RepoHost(), "SearchRepoAssignableActors", &result, variables); err != nil {
return nil, 0, err
}

var actors []AssignableActor
for _, node := range result.Repository.SuggestedActors.Nodes {
if node.User.TypeName == "User" {
actors = append(actors, AssignableUser{
id: node.User.ID,
login: node.User.Login,
name: node.User.Name,
})
} else if node.Bot.TypeName == "Bot" {
actors = append(actors, AssignableBot{
id: node.Bot.ID,
login: node.Bot.Login,
})
}
}

return actors, result.Repository.AssignableUsers.TotalCount, nil
}

type RepoLabel struct {
ID string
Name string
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ require (
golang.org/x/sync v0.20.0
golang.org/x/term v0.40.0
golang.org/x/text v0.34.0
google.golang.org/grpc v1.79.2
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11
gopkg.in/h2non/gock.v1 v1.1.2
gopkg.in/yaml.v3 v3.0.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -640,8 +640,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
Loading
Loading