The gh api command builds its own HTTP client inline without forwarding InvokingAgent, so the User-Agent header was missing the Agent/<name> suffix when invoked by AI coding agents. Thread InvokingAgent through Factory → ApiOptions → HTTPClientOptions, mirroring the existing AppVersion pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
373 lines
13 KiB
Go
373 lines
13 KiB
Go
package factory
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/cli/cli/v2/api"
|
|
ghContext "github.com/cli/cli/v2/context"
|
|
"github.com/cli/cli/v2/git"
|
|
"github.com/cli/cli/v2/internal/browser"
|
|
"github.com/cli/cli/v2/internal/config"
|
|
"github.com/cli/cli/v2/internal/gh"
|
|
"github.com/cli/cli/v2/internal/ghrepo"
|
|
"github.com/cli/cli/v2/internal/prompter"
|
|
"github.com/cli/cli/v2/pkg/cmd/extension"
|
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
|
"github.com/cli/cli/v2/pkg/iostreams"
|
|
xcolor "github.com/cli/go-gh/v2/pkg/x/color"
|
|
)
|
|
|
|
var ssoHeader string
|
|
var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`)
|
|
|
|
func New(appVersion string, invokingAgent string) *cmdutil.Factory {
|
|
f := &cmdutil.Factory{
|
|
AppVersion: appVersion,
|
|
InvokingAgent: invokingAgent,
|
|
Config: configFunc(), // No factory dependencies
|
|
ExecutableName: "gh",
|
|
}
|
|
|
|
f.IOStreams = ioStreams(f) // Depends on Config
|
|
f.HttpClient = httpClientFunc(f, appVersion, invokingAgent) // Depends on Config, IOStreams, appVersion, and invokingAgent
|
|
f.PlainHttpClient = plainHttpClientFunc(f, appVersion, invokingAgent) // Depends on IOStreams, appVersion, and invokingAgent
|
|
f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable
|
|
f.Remotes = remotesFunc(f) // Depends on Config, and GitClient
|
|
f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes
|
|
f.Prompter = newPrompter(f) // Depends on Config and IOStreams
|
|
f.Browser = newBrowser(f) // Depends on Config, and IOStreams
|
|
f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams
|
|
f.Branch = branchFunc(f) // Depends on GitClient
|
|
|
|
return f
|
|
}
|
|
|
|
// BaseRepoFunc requests a list of Remotes, and selects the first one.
|
|
// Although Remotes is injected via the factory so it looks like the function might
|
|
// be configurable, in practice, it's calling readRemotes, and the injection is indirection.
|
|
//
|
|
// readRemotes makes use of the remoteResolver, which is responsible for requesting the list
|
|
// of remotes for the current working directory from git. It then does some filtering to
|
|
// only retain remotes for hosts that we have authenticated against; keep in mind this may
|
|
// be the single value of GH_HOST.
|
|
//
|
|
// That list of remotes is sorted by their remote name, in the following order:
|
|
// 1. upstream
|
|
// 2. github
|
|
// 3. origin
|
|
// 4. other remotes, no ordering guaratanteed because the sort function is not stable
|
|
//
|
|
// Given that list, this function chooses the first one.
|
|
//
|
|
// Here's a common example of when this might matter: when we clone a fork, by default we add
|
|
// the parent as a remote named upstream. So the remotes may look like this:
|
|
// upstream https://github.com/cli/cli.git (fetch)
|
|
// upstream https://github.com/cli/cli.git (push)
|
|
// origin https://github.com/cli/cli-fork.git (fetch)
|
|
// origin https://github.com/cli/cli-fork.git (push)
|
|
//
|
|
// With this resolution function, the upstream will always be chosen (assuming we have authenticated with github.com).
|
|
func BaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) {
|
|
return func() (ghrepo.Interface, error) {
|
|
remotes, err := f.Remotes()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return remotes[0], nil
|
|
}
|
|
}
|
|
|
|
// SmartBaseRepoFunc provides additional behaviour over BaseRepoFunc. Read the BaseRepoFunc
|
|
// documentation for more information on how remotes are fetched and ordered.
|
|
//
|
|
// Unlike BaseRepoFunc, instead of selecting the first remote in the list, this function will
|
|
// use the API to resolve repository networks, and attempt to use the `resolved` git remote config value
|
|
// as part of determining the base repository.
|
|
//
|
|
// Although the behaviour commented below really belongs to the `BaseRepo` function on `ResolvedRemotes`,
|
|
// in practice the most important place to understand the general behaviour is here, so that's where
|
|
// I'm going to write it.
|
|
//
|
|
// Firstly, the remotes are inspected to see whether any are already resolved. Resolution means the git
|
|
// config value of the `resolved` key was `base` (meaning this remote is the base repository), or a specific
|
|
// repository e.g. `cli/cli` (meaning that specific repo is the base repo, regardless of whether a remote
|
|
// exists for it). These values are set by default on clone of a fork, or by running `repo set-default`. If
|
|
// either are set, that repository is returned.
|
|
//
|
|
// If we the current invocation is unable to prompt, then the first remote is returned. I believe this behaviour
|
|
// exists for backwards compatibility before the later steps were introduced, however, this is frequently a source
|
|
// of differing behaviour between interactive and non-interactive invocations:
|
|
//
|
|
// ➜ git remote -v
|
|
// origin https://github.com/williammartin/test-repo.git (fetch)
|
|
// origin https://github.com/williammartin/test-repo.git (push)
|
|
// upstream https://github.com/williammartin-test-org/test-repo.git (fetch)
|
|
// upstream https://github.com/williammartin-test-org/test-repo.git (push)
|
|
//
|
|
// ➜ gh pr list
|
|
// X No default remote repository has been set for this directory.
|
|
//
|
|
// please run `gh repo set-default` to select a default remote repository.
|
|
// ➜ gh pr list | cat
|
|
// 3 test williammartin-test-org:remote-push-default-feature OPEN 2024-12-13T10:28:40Z
|
|
//
|
|
// Furthermore, when repositories have been renamed on the server and not on the local git remote, this causes
|
|
// even more confusion because the API requests can be different, and FURTHERMORE this can be an issue for
|
|
// services that don't handle renames correctly, like the ElasticSearch indexing.
|
|
//
|
|
// Assuming we have an interactive invocation, then the next step is to resolve a network of repositories. This
|
|
// involves creating a dynamic GQL query requesting information about each repository (up to a limit of 5).
|
|
// Each returned repo is added to a list, along with its parent, if present in the query response.
|
|
// The repositories in the query retain the same ordering as previously outlined. Interestingly, the request is sent
|
|
// to the hostname of the first repo, so if you happen to have remotes on different GitHub hosts, then they won't
|
|
// resolve correctly. I'm not sure this has ever caused an issue, but does seem like a potential source of bugs.
|
|
// In practice, since the remotes are ordered with upstream, github, origin before others, it's almost always going
|
|
// to be the case that the correct host is chosen.
|
|
//
|
|
// Because fetching the network includes the parent repo, even if it is not a remote, this requires the user to
|
|
// disambiguate, which can be surprising, though I'm not sure I've heard anyone complain:
|
|
//
|
|
// ➜ git remote -v
|
|
// origin https://github.com/williammartin/test-repo.git (fetch)
|
|
// origin https://github.com/williammartin/test-repo.git (push)
|
|
//
|
|
// ➜ gh pr list
|
|
// X No default remote repository has been set for this directory.
|
|
//
|
|
// please run `gh repo set-default` to select a default remote repository.
|
|
//
|
|
// If no repos are returned from the API then we return the first remote from the original list. I'm not sure
|
|
// why we do this rather than erroring, because it seems like almost every future step is going to fail when hitting
|
|
// the API. Potentially it helps if there is an API blip? It was added without comment in:
|
|
// https://github.com/cli/cli/pull/1706/files#diff-65730f0373fb91dd749940cf09daeaf884e5643d665a6c3eb09d54785a6d475eR113
|
|
//
|
|
// If one repo is returned from the API, then that one is returned as the base repo.
|
|
//
|
|
// If more than one repo is returned from the API, we indicate to the user that they need to run `repo set-default`,
|
|
// and return an error with no base repo.
|
|
func SmartBaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) {
|
|
return func() (ghrepo.Interface, error) {
|
|
httpClient, err := f.HttpClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
apiClient := api.NewClientFromHTTP(httpClient)
|
|
|
|
remotes, err := f.Remotes()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resolvedRepos, err := ghContext.ResolveRemotesToRepos(remotes, apiClient, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
baseRepo, err := resolvedRepos.BaseRepo(f.IOStreams)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return baseRepo, nil
|
|
}
|
|
}
|
|
|
|
func remotesFunc(f *cmdutil.Factory) func() (ghContext.Remotes, error) {
|
|
rr := &remoteResolver{
|
|
readRemotes: func() (git.RemoteSet, error) {
|
|
return f.GitClient.Remotes(context.Background())
|
|
},
|
|
getConfig: f.Config,
|
|
}
|
|
return rr.Resolver()
|
|
}
|
|
|
|
func httpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent string) func() (*http.Client, error) {
|
|
return func() (*http.Client, error) {
|
|
io := f.IOStreams
|
|
cfg, err := f.Config()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
opts := api.HTTPClientOptions{
|
|
Config: cfg.Authentication(),
|
|
Log: io.ErrOut,
|
|
LogColorize: io.ColorEnabled(),
|
|
AppVersion: appVersion,
|
|
InvokingAgent: invokingAgent,
|
|
}
|
|
client, err := api.NewHTTPClient(opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client.Transport = api.ExtractHeader("X-GitHub-SSO", &ssoHeader)(client.Transport)
|
|
return client, nil
|
|
}
|
|
}
|
|
|
|
func plainHttpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent string) func() (*http.Client, error) {
|
|
return func() (*http.Client, error) {
|
|
io := f.IOStreams
|
|
opts := api.HTTPClientOptions{
|
|
Log: io.ErrOut,
|
|
LogColorize: io.ColorEnabled(),
|
|
AppVersion: appVersion,
|
|
InvokingAgent: invokingAgent,
|
|
// This is required to prevent automatic setting of auth and other headers.
|
|
SkipDefaultHeaders: true,
|
|
}
|
|
client, err := api.NewHTTPClient(opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client, nil
|
|
}
|
|
}
|
|
|
|
func newGitClient(f *cmdutil.Factory) *git.Client {
|
|
io := f.IOStreams
|
|
ghPath := f.Executable()
|
|
client := &git.Client{
|
|
GhPath: ghPath,
|
|
Stderr: io.ErrOut,
|
|
Stdin: io.In,
|
|
Stdout: io.Out,
|
|
}
|
|
return client
|
|
}
|
|
|
|
func newBrowser(f *cmdutil.Factory) browser.Browser {
|
|
io := f.IOStreams
|
|
return browser.New("", io.Out, io.ErrOut)
|
|
}
|
|
|
|
func newPrompter(f *cmdutil.Factory) prompter.Prompter {
|
|
editor, _ := cmdutil.DetermineEditor(f.Config)
|
|
io := f.IOStreams
|
|
return prompter.New(editor, io)
|
|
}
|
|
|
|
func configFunc() func() (gh.Config, error) {
|
|
var cachedConfig gh.Config
|
|
var configError error
|
|
return func() (gh.Config, error) {
|
|
if cachedConfig != nil || configError != nil {
|
|
return cachedConfig, configError
|
|
}
|
|
cachedConfig, configError = config.NewConfig()
|
|
return cachedConfig, configError
|
|
}
|
|
}
|
|
|
|
func branchFunc(f *cmdutil.Factory) func() (string, error) {
|
|
return func() (string, error) {
|
|
currentBranch, err := f.GitClient.CurrentBranch(context.Background())
|
|
if err != nil {
|
|
return "", fmt.Errorf("could not determine current branch: %w", err)
|
|
}
|
|
return currentBranch, nil
|
|
}
|
|
}
|
|
|
|
func extensionManager(f *cmdutil.Factory) *extension.Manager {
|
|
em := extension.NewManager(f.IOStreams, f.GitClient)
|
|
|
|
cfg, err := f.Config()
|
|
if err != nil {
|
|
return em
|
|
}
|
|
em.SetConfig(cfg)
|
|
|
|
client, err := f.HttpClient()
|
|
if err != nil {
|
|
return em
|
|
}
|
|
|
|
em.SetClient(api.NewCachedHTTPClient(client, time.Second*30))
|
|
|
|
return em
|
|
}
|
|
|
|
func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams {
|
|
io := iostreams.System()
|
|
cfg, err := f.Config()
|
|
if err != nil {
|
|
return io
|
|
}
|
|
|
|
if _, ghPromptDisabled := os.LookupEnv("GH_PROMPT_DISABLED"); ghPromptDisabled {
|
|
io.SetNeverPrompt(true)
|
|
} else if prompt := cfg.Prompt(""); prompt.Value == "disabled" {
|
|
io.SetNeverPrompt(true)
|
|
}
|
|
|
|
falseyValues := []string{"false", "0", "no", ""}
|
|
|
|
accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER")
|
|
if accessiblePrompterIsSet {
|
|
if !slices.Contains(falseyValues, accessiblePrompterValue) {
|
|
io.SetAccessiblePrompterEnabled(true)
|
|
}
|
|
} else if prompt := cfg.AccessiblePrompter(""); prompt.Value == "enabled" {
|
|
io.SetAccessiblePrompterEnabled(true)
|
|
}
|
|
|
|
experimentalPrompterValue, experimentalPrompterIsSet := os.LookupEnv("GH_EXPERIMENTAL_PROMPTER")
|
|
if experimentalPrompterIsSet {
|
|
if !slices.Contains(falseyValues, experimentalPrompterValue) {
|
|
io.SetExperimentalPrompterEnabled(true)
|
|
}
|
|
}
|
|
|
|
ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED")
|
|
if ghSpinnerDisabledIsSet {
|
|
if !slices.Contains(falseyValues, ghSpinnerDisabledValue) {
|
|
io.SetSpinnerDisabled(true)
|
|
}
|
|
} else if spinnerDisabled := cfg.Spinner(""); spinnerDisabled.Value == "disabled" {
|
|
io.SetSpinnerDisabled(true)
|
|
}
|
|
|
|
// Pager precedence
|
|
// 1. GH_PAGER
|
|
// 2. pager from config
|
|
// 3. PAGER
|
|
if ghPager, ghPagerExists := os.LookupEnv("GH_PAGER"); ghPagerExists {
|
|
io.SetPager(ghPager)
|
|
} else if pager := cfg.Pager(""); pager.Value != "" {
|
|
io.SetPager(pager.Value)
|
|
}
|
|
|
|
if ghColorLabels, ghColorLabelsExists := os.LookupEnv("GH_COLOR_LABELS"); ghColorLabelsExists {
|
|
switch ghColorLabels {
|
|
case "", "0", "false", "no":
|
|
io.SetColorLabels(false)
|
|
default:
|
|
io.SetColorLabels(true)
|
|
}
|
|
} else if prompt := cfg.ColorLabels(""); prompt.Value == "enabled" {
|
|
io.SetColorLabels(true)
|
|
}
|
|
|
|
io.SetAccessibleColorsEnabled(xcolor.IsAccessibleColorsEnabled())
|
|
|
|
return io
|
|
}
|
|
|
|
// SSOURL returns the URL of a SAML SSO challenge received by the server for clients that use ExtractHeader
|
|
// to extract the value of the "X-GitHub-SSO" response header.
|
|
func SSOURL() string {
|
|
if ssoHeader == "" {
|
|
return ""
|
|
}
|
|
m := ssoURLRE.FindStringSubmatch(ssoHeader)
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
return m[1]
|
|
}
|