Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ for _, tt := range tests {
- 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
- Never use em dashes (—) in code, comments, or documentation; use regular dashes (-) or rewrite the sentence instead

## Error Handling

Expand Down
18 changes: 9 additions & 9 deletions internal/skills/discovery/discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -925,21 +925,21 @@ func TestMatchesSkillPath(t *testing.T) {

func TestMatchSkillPath(t *testing.T) {
tests := []struct {
testName string
name string
path string
wantName string
wantNamespace string
}{
{testName: "skills convention", path: "skills/code-review/SKILL.md", wantName: "code-review", wantNamespace: ""},
{testName: "namespaced convention", path: "skills/monalisa/issue-triage/SKILL.md", wantName: "issue-triage", wantNamespace: "monalisa"},
{testName: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary", wantNamespace: "hubot"},
{testName: "non-skill file", path: "README.md", wantName: "", wantNamespace: ""},
{testName: "same name different namespace 1", path: "skills/kynan/commit/SKILL.md", wantName: "commit", wantNamespace: "kynan"},
{testName: "same name different namespace 2", path: "skills/will/commit/SKILL.md", wantName: "commit", wantNamespace: "will"},
{testName: "root convention", path: "my-skill/SKILL.md", wantName: "my-skill", wantNamespace: ""},
{name: "skills convention", path: "skills/code-review/SKILL.md", wantName: "code-review", wantNamespace: ""},
{name: "namespaced convention", path: "skills/monalisa/issue-triage/SKILL.md", wantName: "issue-triage", wantNamespace: "monalisa"},
{name: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary", wantNamespace: "hubot"},
{name: "non-skill file", path: "README.md", wantName: "", wantNamespace: ""},
{name: "same name different namespace 1", path: "skills/kynan/commit/SKILL.md", wantName: "commit", wantNamespace: "kynan"},
{name: "same name different namespace 2", path: "skills/will/commit/SKILL.md", wantName: "commit", wantNamespace: "will"},
{name: "root convention", path: "my-skill/SKILL.md", wantName: "my-skill", wantNamespace: ""},
}
for _, tt := range tests {
t.Run(tt.testName, func(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
name, namespace := MatchSkillPath(tt.path)
assert.Equal(t, tt.wantName, name)
assert.Equal(t, tt.wantNamespace, namespace)
Expand Down
72 changes: 72 additions & 0 deletions pkg/cmd/root/official_extension_stub.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package root

import (
"fmt"

"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)

// NewCmdOfficialExtensionStub creates a hidden stub command for an official
// extension that has not yet been installed. When invoked, it suggests
// installing the extension and, in interactive sessions, offers to do so
// immediately. After a successful install, the extension is dispatched with
// the original arguments.
func NewCmdOfficialExtensionStub(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) *cobra.Command {
cmd := &cobra.Command{
Use: ext.Name,
Short: fmt.Sprintf("Install the official %s extension", ext.Name),
Hidden: true,
GroupID: "extension",
// Accept any args/flags the user may have passed so we don't get
// cobra validation errors before reaching RunE.
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
return officialExtensionStubRun(io, p, em, ext)
},
}

cmdutil.DisableAuthCheck(cmd)

return cmd
}

func officialExtensionStubRun(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) error {
stderr := io.ErrOut

if !io.CanPrompt() {
fmt.Fprint(stderr, heredoc.Docf(`
%[1]s is available as an official extension.
To install it, run:
gh extension install %[2]s/%[3]s
`, fmt.Sprintf("gh %s", ext.Name), ext.Owner, ext.Repo))
return nil
}

prompt := heredoc.Docf(`
%[1]s is available as an official extension.
Would you like to install it now?
`, fmt.Sprintf("gh %s", ext.Name))
confirmed, err := p.Confirm(prompt, true)
if err != nil {
return err
}
if !confirmed {
return nil
}

repo := ext.Repository()
io.StartProgressIndicatorWithLabel(fmt.Sprintf("Installing %s/%s...", ext.Owner, ext.Repo))
installErr := em.Install(repo, "")
io.StopProgressIndicator()
if installErr != nil {
return fmt.Errorf("failed to install extension: %w", installErr)
}

fmt.Fprintf(stderr, "Successfully installed %s/%s\n", ext.Owner, ext.Repo)
return nil
}
119 changes: 119 additions & 0 deletions pkg/cmd/root/official_extension_stub_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package root

import (
"fmt"
"testing"

"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestOfficialExtensionStubRun(t *testing.T) {
ext := &extensions.OfficialExtension{Name: "cool", Owner: "github", Repo: "gh-cool"}

tests := []struct {
name string
isTTY bool
confirmResult bool
confirmErr error
installErr error
wantErr string
wantStderr string
wantInstalled bool
}{
{
name: "non-TTY prints install instructions",
isTTY: false,
wantStderr: "gh extension install github/gh-cool",
},
{
name: "TTY confirmed installs",
isTTY: true,
confirmResult: true,
wantStderr: "Successfully installed github/gh-cool",
wantInstalled: true,
},
{
name: "TTY declined does not install",
isTTY: true,
confirmResult: false,
},
{
name: "TTY prompt error is propagated",
isTTY: true,
confirmErr: fmt.Errorf("prompt interrupted"),
wantErr: "prompt interrupted",
},
{
name: "TTY install error is propagated",
isTTY: true,
confirmResult: true,
installErr: fmt.Errorf("network error"),
wantErr: "network error",
wantInstalled: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, stderr := iostreams.Test()
if tt.isTTY {
ios.SetStdinTTY(true)
ios.SetStdoutTTY(true)
ios.SetStderrTTY(true)
}

em := &extensions.ExtensionManagerMock{
InstallFunc: func(_ ghrepo.Interface, _ string) error {
return tt.installErr
},
}
p := &prompter.PrompterMock{
ConfirmFunc: func(_ string, _ bool) (bool, error) {
return tt.confirmResult, tt.confirmErr
},
}

err := officialExtensionStubRun(ios, p, em, ext)

if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
} else {
require.NoError(t, err)
}

if tt.wantStderr != "" {
assert.Contains(t, stderr.String(), tt.wantStderr)
}

if tt.wantInstalled {
require.NotEmpty(t, em.InstallCalls())
repo := em.InstallCalls()[0].InterfaceMoqParam
assert.Equal(t, "github", repo.RepoOwner())
assert.Equal(t, "gh-cool", repo.RepoName())
assert.Equal(t, "github.com", repo.RepoHost())
} else if tt.isTTY && !tt.confirmResult && tt.confirmErr == nil {
assert.Empty(t, em.InstallCalls())
}
})
}
}

func TestNewCmdOfficialExtensionStub_Properties(t *testing.T) {
ios, _, _, _ := iostreams.Test()
ext := &extensions.OfficialExtension{Name: "cool", Owner: "github", Repo: "gh-cool"}
em := &extensions.ExtensionManagerMock{}
p := &prompter.PrompterMock{}

cmd := NewCmdOfficialExtensionStub(ios, p, em, ext)

assert.Equal(t, "cool", cmd.Use)
assert.True(t, cmd.Hidden)
assert.Equal(t, "extension", cmd.GroupID)
assert.True(t, cmd.DisableFlagParsing)
}
12 changes: 12 additions & 0 deletions pkg/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import (
versionCmd "github.com/cli/cli/v2/pkg/cmd/version"
workflowCmd "github.com/cli/cli/v2/pkg/cmd/workflow"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/google/shlex"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -231,6 +232,17 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command,
}
}

// Official extension stubs: hidden commands that suggest installing
// GitHub-owned extensions when invoked. Registered after real extensions
// and aliases so that both take priority over stubs.
for i := range extensions.OfficialExtensions {
ext := &extensions.OfficialExtensions[i]
if _, _, err := cmd.Find([]string{ext.Name}); err == nil {
continue
}
cmd.AddCommand(NewCmdOfficialExtensionStub(io, f.Prompter, em, ext))
}

cmdutil.DisableAuthCheck(cmd)

// The reference command produces paged output that displays information on every other command.
Expand Down
41 changes: 12 additions & 29 deletions pkg/cmd/skills/publish/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ type PublishOptions struct {
DryRun bool
Tag string

client *api.Client // injectable for tests; nil means use factory
host string // resolved from config in production
host string // resolved from config in production
}

// publishDiagnostic is a single validation finding.
Expand Down Expand Up @@ -178,11 +177,10 @@ func publishRun(opts *PublishOptions) error {

canPrompt := opts.IO.CanPrompt()

// Use injected client or create one from the factory HttpClient.
// Initialization is deferred until after local validation so that
// Client initialization is deferred until after local validation so that
// simple errors (missing skills/, bad SKILL.md, etc.) are reported
// without requiring an HTTP client.
client := opts.client
var client *api.Client
host := opts.host

var diagnostics []publishDiagnostic
Expand Down Expand Up @@ -343,14 +341,11 @@ func publishRun(opts *PublishOptions) error {
hasTopic := false
var existingTags []tagEntry
if owner != "" && repo != "" {
// Create API client from factory if not already injected (tests inject directly).
if client == nil {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
client = api.NewClientFromHTTP(httpClient)
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
client = api.NewClientFromHTTP(httpClient)

if host == "" && repoInfo != nil {
host = repoInfo.Repo.RepoHost()
Expand Down Expand Up @@ -658,10 +653,11 @@ func ensurePushed(opts *PublishOptions, dir, remoteName string) error {
}

ref := fmt.Sprintf("HEAD:refs/heads/%s", currentBranch)
fmt.Fprintf(opts.IO.ErrOut, "%s Pushing %s to %s\n", cs.SuccessIcon(), currentBranch, remoteName)
fmt.Fprintf(opts.IO.ErrOut, "Pushing %s to %s...\n", currentBranch, remoteName)
if err := gitClient.Push(ctx, remoteName, ref); err != nil {
return fmt.Errorf("failed to push branch %s: %w", currentBranch, err)
}
fmt.Fprintf(opts.IO.ErrOut, "%s Pushed %s to %s\n", cs.SuccessIcon(), currentBranch, remoteName)

return nil
}
Expand Down Expand Up @@ -924,7 +920,9 @@ type gitHubRemote struct {
}

// detectGitHubRemote attempts to detect the GitHub owner/repo from git remotes
// in the given directory.
// in the given directory. Remotes are tried in the order returned by
// gitClient.Remotes (upstream > github > origin > rest), so the first
// GitHub-pointing remote wins.
func detectGitHubRemote(gitClient *git.Client, dir string) (*gitHubRemote, error) {
if gitClient == nil {
return nil, nil
Expand All @@ -933,26 +931,11 @@ func detectGitHubRemote(gitClient *git.Client, dir string) (*gitHubRemote, error
dirClient := gitClient.Copy()
dirClient.RepoDir = dir

// Try origin first
if url, err := dirClient.RemoteURL(context.Background(), "origin"); err == nil {
repo, parseErr := parseGitHubURL(url)
if parseErr != nil {
return nil, parseErr
}
if repo != nil {
return &gitHubRemote{Repo: repo, RemoteName: "origin"}, nil
}
}

// Fall back to any remote that points to GitHub
remotes, err := dirClient.Remotes(context.Background())
if err != nil {
return nil, nil //nolint:nilerr // failing to list remotes is not an error; it just means no repo detected
}
for _, r := range remotes {
if r.Name == "origin" {
continue
}
if url, err := dirClient.RemoteURL(context.Background(), r.Name); err == nil {
repo, parseErr := parseGitHubURL(url)
if parseErr != nil {
Expand Down
Loading
Loading