Thanks to visit codestin.com
Credit goes to github.com

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
13 changes: 13 additions & 0 deletions acceptance/testdata/skills/skills-install.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,16 @@ stdout 'Installed git-commit'
# Verify the skill was written to the custom directory
exists $WORK/custom-skills/git-commit/SKILL.md
grep 'github-repo' $WORK/custom-skills/git-commit/SKILL.md

# Telemetry: skill_install event records agent hosts, repo identifiers,
# and (for a public repo) the installed skill name.
env GH_PRIVATE_ENABLE_TELEMETRY=1
env GH_TELEMETRY=log
env GH_TELEMETRY_SAMPLE_RATE=100
exec gh skill install github/awesome-copilot git-commit --scope user --force --agent github-copilot
stderr 'Telemetry payload:'
stderr '"type": "skill_install"'
stderr '"agent_hosts": "github-copilot"'
stderr '"skill_host_type": "github.com"'
stderr '"skill_owner": "github"'
stderr '"skill_repo": "awesome-copilot"'
12 changes: 12 additions & 0 deletions acceptance/testdata/skills/skills-preview.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,15 @@ stdout 'git-commit/'
# Preview a skill that doesn't exist should error
! exec gh skill preview github/awesome-copilot nonexistent-skill-xyz
stderr 'not found'

# Telemetry: skill_preview event records repo identifiers and, for a
# public repo, the skill name.
env GH_PRIVATE_ENABLE_TELEMETRY=1
env GH_TELEMETRY=log
env GH_TELEMETRY_SAMPLE_RATE=100
exec gh skill preview github/awesome-copilot git-commit
stderr 'Telemetry payload:'
stderr '"type": "skill_preview"'
stderr '"skill_host_type": "github.com"'
stderr '"skill_owner": "github"'
stderr '"skill_repo": "awesome-copilot"'
4 changes: 0 additions & 4 deletions acceptance/testdata/skills/skills-publish-dry-run.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@ stderr 'no skills found in'
exec gh skill publish --dry-run $WORK/test-repo
stdout 'hello-world'

# Validate alias should work identically
exec gh skill validate --dry-run $WORK/test-repo
stdout 'hello-world'

# Publish dry-run with --tag
exec gh skill publish --dry-run --tag v1.0.0 $WORK/test-repo
stdout 'hello-world'
Expand Down
2 changes: 1 addition & 1 deletion acceptance/testdata/skills/skills-search-page.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Pagination returns results on page 2
exec gh skill search copilot --page 2
exec gh skill search --owner github copilot --page 2
stdout 'copilot'
4 changes: 2 additions & 2 deletions acceptance/testdata/skills/skills-search.txtar
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Search for skills matching a query
exec gh skill search copilot
exec gh skill search --owner github copilot
stdout 'copilot'

# Search with JSON output
Expand All @@ -9,4 +9,4 @@ stdout '"repo"'

# Search with a short query should error
! exec gh skill search a
stderr 'at least'
stderr 'at least'
16 changes: 16 additions & 0 deletions internal/ghinstance/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,19 @@ func HostPrefix(hostname string) string {
}
return fmt.Sprintf("https://%s/", hostname)
}

func CategorizeHost(host string) string {
if host == defaultHostname {
return "github.com"
}

if ghauth.IsEnterprise(host) {
return "ghes"
}

if ghauth.IsTenancy(host) {
return "tenancy"
}

return "uncategorized"
}
54 changes: 54 additions & 0 deletions internal/ghinstance/host_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,57 @@ func TestRESTPrefix(t *testing.T) {
})
}
}

func TestCategorizeHost(t *testing.T) {
tests := []struct {
name string
host string
want string
}{
{
name: "github.com returns github.com",
host: "github.com",
want: "github.com",
},
{
name: "classic GHES hostname returns ghes",
host: "ghe.io",
want: "ghes",
},
{
name: "arbitrary enterprise hostname returns ghes",
host: "enterprise.example.com",
want: "ghes",
},
{
name: "tenant subdomain of ghe.com returns tenancy",
host: "tenant.ghe.com",
want: "tenancy",
},
{
name: "api subdomain under tenant returns tenancy",
host: "api.tenant.ghe.com",
want: "tenancy",
},
{
name: "bare ghe.com returns ghes",
host: "ghe.com",
want: "ghes",
},
{
name: "github.localhost returns uncategorized",
host: "github.localhost",
want: "uncategorized",
},
{
name: "github.com subdomain returns uncategorized",
host: "garage.github.com",
want: "uncategorized",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, CategorizeHost(tt.host))
})
}
}
69 changes: 48 additions & 21 deletions internal/skills/discovery/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,18 +126,37 @@ type treeResponse struct {
Truncated bool `json:"truncated"`
}

type blobResponse struct {
SHA string `json:"sha"`
Content string `json:"content"`
Encoding string `json:"encoding"`
}
type RepoVisibility string

const (
RepoVisibilityPublic RepoVisibility = "public"
RepoVisibilityPrivate RepoVisibility = "private"
RepoVisibilityInternal RepoVisibility = "internal"
)

type releaseResponse struct {
TagName string `json:"tag_name"`
func parseRepoVisibility(s string) (RepoVisibility, error) {
switch s {
case "public":
return RepoVisibilityPublic, nil
case "private":
return RepoVisibilityPrivate, nil
case "internal":
return RepoVisibilityInternal, nil
default:
return "", fmt.Errorf("unknown repository visibility: %q", s)
}
}

type repoResponse struct {
DefaultBranch string `json:"default_branch"`
// FetchRepoVisibility returns the repository visibility: "public", "private", or "internal".
func FetchRepoVisibility(client *api.Client, host, owner, repo string) (RepoVisibility, error) {
apiPath := fmt.Sprintf("repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo))
var resp struct {
Visibility string `json:"visibility"`
}
if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil {
return "", err
}
return parseRepoVisibility(resp.Visibility)
}

// ResolveRef determines the git ref to use for a given owner/repo.
Expand Down Expand Up @@ -266,8 +285,10 @@ func (e *noReleasesError) Error() string { return e.reason }

func resolveLatestRelease(client *api.Client, host, owner, repo string) (*ResolvedRef, error) {
apiPath := fmt.Sprintf("repos/%s/%s/releases/latest", url.PathEscape(owner), url.PathEscape(repo))
var release releaseResponse
if err := client.REST(host, "GET", apiPath, nil, &release); err != nil {
var resp struct {
TagName string `json:"tag_name"`
}
if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil {
// A 404 means the repository has no releases. This is the
// only case where falling back to the default branch is safe.
// Any other HTTP error (403, 500, …) or network failure is
Expand All @@ -278,19 +299,21 @@ func resolveLatestRelease(client *api.Client, host, owner, repo string) (*Resolv
}
return nil, fmt.Errorf("could not fetch latest release: %w", err)
}
if release.TagName == "" {
if resp.TagName == "" {
return nil, &noReleasesError{reason: "latest release has no tag"}
}
return resolveTagRef(client, host, owner, repo, release.TagName)
return resolveTagRef(client, host, owner, repo, resp.TagName)
}

func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*ResolvedRef, error) {
apiPath := fmt.Sprintf("repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo))
var repoResp repoResponse
if err := client.REST(host, "GET", apiPath, nil, &repoResp); err != nil {
var resp struct {
DefaultBranch string `json:"default_branch"`
}
if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil {
return nil, fmt.Errorf("could not determine default branch: %w", err)
}
branch := repoResp.DefaultBranch
branch := resp.DefaultBranch
if branch == "" {
return nil, fmt.Errorf("could not determine default branch for %s/%s", owner, repo)
}
Expand Down Expand Up @@ -657,18 +680,22 @@ func walkTree(client *api.Client, host, owner, repo, sha, prefix string, depth i
// FetchBlob retrieves the content of a blob by SHA.
func FetchBlob(client *api.Client, host, owner, repo, sha string) (string, error) {
apiPath := fmt.Sprintf("repos/%s/%s/git/blobs/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
var blob blobResponse
if err := client.REST(host, "GET", apiPath, nil, &blob); err != nil {
var resp struct {
SHA string `json:"sha"`
Content string `json:"content"`
Encoding string `json:"encoding"`
}
if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil {
return "", fmt.Errorf("could not fetch blob: %w", err)
}

if blob.Encoding != "base64" {
return "", fmt.Errorf("unexpected blob encoding: %s", blob.Encoding)
if resp.Encoding != "base64" {
return "", fmt.Errorf("unexpected blob encoding: %s", resp.Encoding)
}

// GitHub API returns base64 with embedded newlines; use the StdEncoding
// decoder via a reader to handle them transparently.
decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(blob.Content)))
decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(resp.Content)))
if err != nil {
return "", fmt.Errorf("could not decode blob content: %w", err)
}
Expand Down
80 changes: 80 additions & 0 deletions internal/skills/discovery/discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,86 @@ func TestFetchBlob(t *testing.T) {
}
}

func TestFetchRepoVisibility(t *testing.T) {
tests := []struct {
name string
stubs func(*httpmock.Registry)
want RepoVisibility
wantErr string
}{
{
name: "public repo",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills"),
httpmock.JSONResponse(map[string]interface{}{
"visibility": "public",
}))
},
want: RepoVisibilityPublic,
},
Comment thread
williammartin marked this conversation as resolved.
{
name: "private repo",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills"),
httpmock.JSONResponse(map[string]interface{}{
"visibility": "private",
}))
},
want: RepoVisibilityPrivate,
},
{
name: "internal repo",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills"),
httpmock.JSONResponse(map[string]interface{}{
"visibility": "internal",
}))
},
want: RepoVisibilityInternal,
},
{
name: "unknown visibility",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills"),
httpmock.JSONResponse(map[string]interface{}{
"visibility": "cool-visibility",
}))
},
wantErr: `unknown repository visibility: "cool-visibility"`,
},
{
name: "API error",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills"),
httpmock.StatusStringResponse(500, "server error"))
},
wantErr: "HTTP 500",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
tt.stubs(reg)
client := api.NewClientFromHTTP(&http.Client{Transport: reg})

got, err := FetchRepoVisibility(client, "github.com", "monalisa", "octocat-skills")
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

func TestDiscoverSkills(t *testing.T) {
tests := []struct {
name string
Expand Down
20 changes: 20 additions & 0 deletions internal/telemetry/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,23 @@ func (r *EventRecorderSpy) Record(event ghtelemetry.Event) {
func (r *EventRecorderSpy) Disable() {}

func (r *EventRecorderSpy) Flush() {}

// CommandRecorderSpy is a test double for ghtelemetry.CommandRecorder.
// It captures recorded events and the most recent SetSampleRate call so tests can
// assert on the sampling behavior commands attempt to configure.
type CommandRecorderSpy struct {
Events []ghtelemetry.Event
LastSampleRate int
}

func (r *CommandRecorderSpy) Record(event ghtelemetry.Event) {
r.Events = append(r.Events, event)
}

func (r *CommandRecorderSpy) Disable() {}

func (r *CommandRecorderSpy) SetSampleRate(rate int) {
r.LastSampleRate = rate
}

func (r *CommandRecorderSpy) Flush() {}
2 changes: 1 addition & 1 deletion pkg/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ func NewCmdRoot(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, versi
cmd.AddCommand(codespaceCmd.NewCmdCodespace(f))
cmd.AddCommand(projectCmd.NewCmdProject(f))
cmd.AddCommand(previewCmd.NewCmdPreview(f))
cmd.AddCommand(skillsCmd.NewCmdSkills(f))
cmd.AddCommand(skillsCmd.NewCmdSkills(f, telemetry))

// Root commands with standalone functionality and no subcommands
cmd.AddCommand(copilotCmd.NewCmdCopilot(f, nil))
Expand Down
Loading
Loading