Skip to content

Commit bce04e3

Browse files
Merge branch 'trunk' into sammorrowdrums/skills-replace-git-in-publish-tests
2 parents 5ae5d1d + d7961f1 commit bce04e3

8 files changed

Lines changed: 258 additions & 10 deletions

File tree

.github/CODEOWNERS

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
* @cli/code-reviewers
22

3-
pkg/cmd/codespace/ @cli/codespaces
4-
internal/codespaces/ @cli/codespaces
3+
pkg/cmd/codespace/ @cli/codespaces @cli/code-reviewers
4+
internal/codespaces/ @cli/codespaces @cli/code-reviewers
55

66
# Limit Package Security team ownership to the attestation command package and related integration tests
7-
pkg/cmd/attestation/ @cli/package-security
8-
pkg/cmd/release/attestation/ @cli/package-security
9-
pkg/cmd/release/verify/ @cli/package-security
10-
pkg/cmd/release/verify-asset/ @cli/package-security
11-
pkg/cmd/release/shared/ @cli/package-security
7+
pkg/cmd/attestation/ @cli/package-security @cli/code-reviewers
8+
pkg/cmd/release/attestation/ @cli/package-security @cli/code-reviewers
9+
pkg/cmd/release/verify/ @cli/package-security @cli/code-reviewers
10+
pkg/cmd/release/verify-asset/ @cli/package-security @cli/code-reviewers
11+
pkg/cmd/release/shared/ @cli/package-security @cli/code-reviewers
1212

13-
test/integration/attestation-cmd @cli/package-security
13+
test/integration/attestation-cmd @cli/package-security @cli/code-reviewers
1414

15-
pkg/cmd/attestation/verification/embed/tuf-repo.github.com/ @cli/tuf-root-reviewers
15+
pkg/cmd/attestation/verification/embed/tuf-repo.github.com/ @cli/tuf-root-reviewers @cli/code-reviewers
16+
17+
pkg/cmd/skills/ @cli/skill-reviewers @cli/code-reviewers
18+
internal/skills/ @cli/skill-reviewers @cli/code-reviewers

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ for _, tt := range tests {
135135
- Add godoc comments to all exported functions, types, and constants
136136
- Avoid unnecessary code comments — only comment when the *why* isn't obvious from the code
137137
- Do not comment just to restate what the code does
138+
- Never use em dashes (—) in code, comments, or documentation; use regular dashes (-) or rewrite the sentence instead
138139

139140
## Error Handling
140141

acceptance/testdata/skills/skills-publish-dry-run.txtar

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Publish dry-run from a directory with no skills/ should fail gracefully
22
! exec gh skill publish --dry-run $WORK
3-
stderr 'no skills/ directory found'
3+
stderr 'no skills found in'
44

55
# Publish dry-run against a valid skill directory should succeed
66
exec gh skill publish --dry-run $WORK/test-repo
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package root
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/MakeNowJust/heredoc"
7+
"github.com/cli/cli/v2/internal/prompter"
8+
"github.com/cli/cli/v2/pkg/cmdutil"
9+
"github.com/cli/cli/v2/pkg/extensions"
10+
"github.com/cli/cli/v2/pkg/iostreams"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
// NewCmdOfficialExtensionStub creates a hidden stub command for an official
15+
// extension that has not yet been installed. When invoked, it suggests
16+
// installing the extension and, in interactive sessions, offers to do so
17+
// immediately. After a successful install, the extension is dispatched with
18+
// the original arguments.
19+
func NewCmdOfficialExtensionStub(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) *cobra.Command {
20+
cmd := &cobra.Command{
21+
Use: ext.Name,
22+
Short: fmt.Sprintf("Install the official %s extension", ext.Name),
23+
Hidden: true,
24+
GroupID: "extension",
25+
// Accept any args/flags the user may have passed so we don't get
26+
// cobra validation errors before reaching RunE.
27+
DisableFlagParsing: true,
28+
RunE: func(cmd *cobra.Command, args []string) error {
29+
return officialExtensionStubRun(io, p, em, ext)
30+
},
31+
}
32+
33+
cmdutil.DisableAuthCheck(cmd)
34+
35+
return cmd
36+
}
37+
38+
func officialExtensionStubRun(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) error {
39+
stderr := io.ErrOut
40+
41+
if !io.CanPrompt() {
42+
fmt.Fprint(stderr, heredoc.Docf(`
43+
%[1]s is available as an official extension.
44+
To install it, run:
45+
gh extension install %[2]s/%[3]s
46+
`, fmt.Sprintf("gh %s", ext.Name), ext.Owner, ext.Repo))
47+
return nil
48+
}
49+
50+
prompt := heredoc.Docf(`
51+
%[1]s is available as an official extension.
52+
Would you like to install it now?
53+
`, fmt.Sprintf("gh %s", ext.Name))
54+
confirmed, err := p.Confirm(prompt, true)
55+
if err != nil {
56+
return err
57+
}
58+
if !confirmed {
59+
return nil
60+
}
61+
62+
repo := ext.Repository()
63+
io.StartProgressIndicatorWithLabel(fmt.Sprintf("Installing %s/%s...", ext.Owner, ext.Repo))
64+
installErr := em.Install(repo, "")
65+
io.StopProgressIndicator()
66+
if installErr != nil {
67+
return fmt.Errorf("failed to install extension: %w", installErr)
68+
}
69+
70+
fmt.Fprintf(stderr, "Successfully installed %s/%s\n", ext.Owner, ext.Repo)
71+
return nil
72+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package root
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/cli/cli/v2/internal/ghrepo"
8+
"github.com/cli/cli/v2/internal/prompter"
9+
"github.com/cli/cli/v2/pkg/extensions"
10+
"github.com/cli/cli/v2/pkg/iostreams"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestOfficialExtensionStubRun(t *testing.T) {
16+
ext := &extensions.OfficialExtension{Name: "cool", Owner: "github", Repo: "gh-cool"}
17+
18+
tests := []struct {
19+
name string
20+
isTTY bool
21+
confirmResult bool
22+
confirmErr error
23+
installErr error
24+
wantErr string
25+
wantStderr string
26+
wantInstalled bool
27+
}{
28+
{
29+
name: "non-TTY prints install instructions",
30+
isTTY: false,
31+
wantStderr: "gh extension install github/gh-cool",
32+
},
33+
{
34+
name: "TTY confirmed installs",
35+
isTTY: true,
36+
confirmResult: true,
37+
wantStderr: "Successfully installed github/gh-cool",
38+
wantInstalled: true,
39+
},
40+
{
41+
name: "TTY declined does not install",
42+
isTTY: true,
43+
confirmResult: false,
44+
},
45+
{
46+
name: "TTY prompt error is propagated",
47+
isTTY: true,
48+
confirmErr: fmt.Errorf("prompt interrupted"),
49+
wantErr: "prompt interrupted",
50+
},
51+
{
52+
name: "TTY install error is propagated",
53+
isTTY: true,
54+
confirmResult: true,
55+
installErr: fmt.Errorf("network error"),
56+
wantErr: "network error",
57+
wantInstalled: true,
58+
},
59+
}
60+
61+
for _, tt := range tests {
62+
t.Run(tt.name, func(t *testing.T) {
63+
ios, _, _, stderr := iostreams.Test()
64+
if tt.isTTY {
65+
ios.SetStdinTTY(true)
66+
ios.SetStdoutTTY(true)
67+
ios.SetStderrTTY(true)
68+
}
69+
70+
em := &extensions.ExtensionManagerMock{
71+
InstallFunc: func(_ ghrepo.Interface, _ string) error {
72+
return tt.installErr
73+
},
74+
}
75+
p := &prompter.PrompterMock{
76+
ConfirmFunc: func(_ string, _ bool) (bool, error) {
77+
return tt.confirmResult, tt.confirmErr
78+
},
79+
}
80+
81+
err := officialExtensionStubRun(ios, p, em, ext)
82+
83+
if tt.wantErr != "" {
84+
require.Error(t, err)
85+
assert.Contains(t, err.Error(), tt.wantErr)
86+
} else {
87+
require.NoError(t, err)
88+
}
89+
90+
if tt.wantStderr != "" {
91+
assert.Contains(t, stderr.String(), tt.wantStderr)
92+
}
93+
94+
if tt.wantInstalled {
95+
require.NotEmpty(t, em.InstallCalls())
96+
repo := em.InstallCalls()[0].InterfaceMoqParam
97+
assert.Equal(t, "github", repo.RepoOwner())
98+
assert.Equal(t, "gh-cool", repo.RepoName())
99+
assert.Equal(t, "github.com", repo.RepoHost())
100+
} else if tt.isTTY && !tt.confirmResult && tt.confirmErr == nil {
101+
assert.Empty(t, em.InstallCalls())
102+
}
103+
})
104+
}
105+
}
106+
107+
func TestNewCmdOfficialExtensionStub_Properties(t *testing.T) {
108+
ios, _, _, _ := iostreams.Test()
109+
ext := &extensions.OfficialExtension{Name: "cool", Owner: "github", Repo: "gh-cool"}
110+
em := &extensions.ExtensionManagerMock{}
111+
p := &prompter.PrompterMock{}
112+
113+
cmd := NewCmdOfficialExtensionStub(ios, p, em, ext)
114+
115+
assert.Equal(t, "cool", cmd.Use)
116+
assert.True(t, cmd.Hidden)
117+
assert.Equal(t, "extension", cmd.GroupID)
118+
assert.True(t, cmd.DisableFlagParsing)
119+
}

pkg/cmd/root/root.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import (
4545
versionCmd "github.com/cli/cli/v2/pkg/cmd/version"
4646
workflowCmd "github.com/cli/cli/v2/pkg/cmd/workflow"
4747
"github.com/cli/cli/v2/pkg/cmdutil"
48+
"github.com/cli/cli/v2/pkg/extensions"
4849
"github.com/google/shlex"
4950
"github.com/spf13/cobra"
5051
)
@@ -231,6 +232,17 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command,
231232
}
232233
}
233234

235+
// Official extension stubs: hidden commands that suggest installing
236+
// GitHub-owned extensions when invoked. Registered after real extensions
237+
// and aliases so that both take priority over stubs.
238+
for i := range extensions.OfficialExtensions {
239+
ext := &extensions.OfficialExtensions[i]
240+
if _, _, err := cmd.Find([]string{ext.Name}); err == nil {
241+
continue
242+
}
243+
cmd.AddCommand(NewCmdOfficialExtensionStub(io, f.Prompter, em, ext))
244+
}
245+
234246
cmdutil.DisableAuthCheck(cmd)
235247

236248
// The reference command produces paged output that displays information on every other command.

pkg/extensions/official.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package extensions
2+
3+
import (
4+
"github.com/cli/cli/v2/internal/ghrepo"
5+
)
6+
7+
// OfficialExtension describes a GitHub-owned CLI extension that can be
8+
// suggested to users when they invoke an unknown command.
9+
type OfficialExtension struct {
10+
Name string
11+
Owner string
12+
Repo string
13+
}
14+
15+
// Repository returns a ghrepo.Interface pinned to github.com so that GHES
16+
// users install from github.com rather than their enterprise host.
17+
func (e *OfficialExtension) Repository() ghrepo.Interface {
18+
return ghrepo.NewWithHost(e.Owner, e.Repo, "github.com")
19+
}
20+
21+
// OfficialExtensions is the registry of GitHub-owned extensions that gh will
22+
// offer to install when the user invokes the corresponding command name.
23+
var OfficialExtensions = []OfficialExtension{
24+
{Name: "aw", Owner: "github", Repo: "gh-aw"},
25+
{Name: "stack", Owner: "github", Repo: "gh-stack"},
26+
}

pkg/extensions/official_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package extensions
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestOfficialExtension_Repository(t *testing.T) {
10+
ext := &OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"}
11+
repo := ext.Repository()
12+
assert.Equal(t, "github", repo.RepoOwner())
13+
assert.Equal(t, "gh-stack", repo.RepoName())
14+
assert.Equal(t, "github.com", repo.RepoHost())
15+
}

0 commit comments

Comments
 (0)