diff --git a/cmd/wfctl/plugin.go b/cmd/wfctl/plugin.go index 4f4b4b38..41751486 100644 --- a/cmd/wfctl/plugin.go +++ b/cmd/wfctl/plugin.go @@ -41,6 +41,8 @@ func runPlugin(args []string) error { return runPluginInfo(args[1:]) case "deps": return runPluginDeps(args[1:]) + case "marketplace-verify": + return runPluginMarketplaceVerify(args[1:]) default: return pluginUsage() } @@ -64,6 +66,7 @@ Subcommands: conformance Run executable plugin/host conformance checks info Show details about an installed plugin deps List dependencies for a plugin + marketplace-verify Scan a GitHub org's wfctl.yaml files for plugin usage; suggests manifest status (verified | experimental) Use -plugin-dir to specify a custom plugin directory (replaces deprecated -data-dir). `) diff --git a/cmd/wfctl/plugin_marketplace_verify.go b/cmd/wfctl/plugin_marketplace_verify.go new file mode 100644 index 00000000..b019fbbc --- /dev/null +++ b/cmd/wfctl/plugin_marketplace_verify.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "os/exec" + "strings" +) + +// runPluginMarketplaceVerify scans a GitHub org for merged main-branch wfctl.yaml files +// that reference the given plugin. Reports whether the plugin's registry +// status should be "verified" (>=1 active pin) or "experimental" (no pins). +// +// Backed by `gh api` (the official GitHub CLI) so the subcommand inherits the +// operator's existing GitHub auth and rate-limit budget. No new auth surface. +func runPluginMarketplaceVerify(args []string) error { + fs := flag.NewFlagSet("plugin verify", flag.ContinueOnError) + org := fs.String("org", "GoCodeAlone", "GitHub org to scan") + jsonOut := fs.Bool("json", false, "Output JSON instead of human-readable text") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: wfctl plugin verify [options] + +Scan a GitHub org for merged main-branch wfctl.yaml files that pin the +plugin. Reports the suggested registry status: + + - "verified" >=1 active pin in a main-branch wfctl.yaml + - "experimental" no active pins + +Options: +`) + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() < 1 { + fs.Usage() + return fmt.Errorf("plugin name is required") + } + pluginName := fs.Arg(0) + + pins, err := searchOrgForPluginPins(context.Background(), *org, pluginName, ghAPICmd) + if err != nil { + return fmt.Errorf("search org: %w", err) + } + + verdict := "experimental" + if len(pins) > 0 { + verdict = "verified" + } + + if *jsonOut { + report := map[string]any{ + "plugin": pluginName, + "org": *org, + "status": verdict, + "pin_count": len(pins), + "pinned_in": pins, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(report) + } + + fmt.Printf("Plugin: %s\n", pluginName) + fmt.Printf("Org: %s\n", *org) + fmt.Printf("Pins: %d\n", len(pins)) + fmt.Printf("Verdict: %s\n", verdict) + if len(pins) > 0 { + fmt.Println("Pinned in:") + for _, p := range pins { + fmt.Printf(" - %s\n", p) + } + } + if verdict == "experimental" { + fmt.Println("\nNo active main-branch pins found. Manifest status should be 'experimental'.") + } else { + fmt.Println("\nActive pins found. Manifest status should be 'verified'.") + } + return nil +} + +// ghAPICmd is the indirection point so tests can inject a fake gh binary. +// Default is the real `gh api` CLI. +var ghAPICmd = func(ctx context.Context, endpoint string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "gh", "api", endpoint) + return cmd.Output() +} + +// searchOrgForPluginPins queries the GitHub code-search API for `name: +// ` occurrences inside wfctl.yaml files within the org. Returns a +// list of repo+path strings. +func searchOrgForPluginPins(ctx context.Context, org, plugin string, ghAPI func(context.Context, string) ([]byte, error)) ([]string, error) { + query := fmt.Sprintf(`filename:wfctl.yaml org:%s "name: workflow-plugin-%s"`, org, plugin) + endpoint := fmt.Sprintf("search/code?q=%s&per_page=100", urlQueryEscape(query)) + + body, err := ghAPI(ctx, endpoint) + if err != nil { + return nil, fmt.Errorf("gh api search: %w", err) + } + + var result struct { + TotalCount int `json:"total_count"` + Items []struct { + Path string `json:"path"` + Repository struct { + FullName string `json:"full_name"` + } `json:"repository"` + } `json:"items"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("decode search response: %w", err) + } + + pins := make([]string, 0, len(result.Items)) + for _, item := range result.Items { + pins = append(pins, fmt.Sprintf("%s/%s", item.Repository.FullName, item.Path)) + } + return pins, nil +} + +// urlQueryEscape minimal escape for the GitHub code-search query string. +// We rely on `gh api` to handle most encoding; only spaces, quotes, and +// colons need percent-escaping for the endpoint URL. +func urlQueryEscape(q string) string { + r := strings.NewReplacer( + " ", "%20", + `"`, "%22", + ":", "%3A", + ) + return r.Replace(q) +} diff --git a/cmd/wfctl/plugin_marketplace_verify_test.go b/cmd/wfctl/plugin_marketplace_verify_test.go new file mode 100644 index 00000000..65a2fd49 --- /dev/null +++ b/cmd/wfctl/plugin_marketplace_verify_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestSearchOrgForPluginPins_Verified(t *testing.T) { + fake := func(ctx context.Context, endpoint string) ([]byte, error) { + return []byte(`{ + "total_count": 2, + "items": [ + {"path": "wfctl.yaml", "repository": {"full_name": "GoCodeAlone/buymywishlist"}}, + {"path": "wfctl.yaml", "repository": {"full_name": "GoCodeAlone/core-dump"}} + ] + }`), nil + } + pins, err := searchOrgForPluginPins(context.Background(), "GoCodeAlone", "digitalocean", fake) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(pins) != 2 { + t.Fatalf("expected 2 pins, got %d: %v", len(pins), pins) + } + if pins[0] != "GoCodeAlone/buymywishlist/wfctl.yaml" { + t.Errorf("pin[0]=%q want GoCodeAlone/buymywishlist/wfctl.yaml", pins[0]) + } +} + +func TestSearchOrgForPluginPins_Experimental(t *testing.T) { + fake := func(ctx context.Context, endpoint string) ([]byte, error) { + return []byte(`{"total_count": 0, "items": []}`), nil + } + pins, err := searchOrgForPluginPins(context.Background(), "GoCodeAlone", "aws", fake) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(pins) != 0 { + t.Fatalf("expected 0 pins, got %d: %v", len(pins), pins) + } +} + +func TestSearchOrgForPluginPins_GHAPIError(t *testing.T) { + fake := func(ctx context.Context, endpoint string) ([]byte, error) { + return nil, errors.New("rate limit") + } + _, err := searchOrgForPluginPins(context.Background(), "GoCodeAlone", "aws", fake) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestSearchOrgForPluginPins_BadJSON(t *testing.T) { + fake := func(ctx context.Context, endpoint string) ([]byte, error) { + return []byte(`not json`), nil + } + _, err := searchOrgForPluginPins(context.Background(), "GoCodeAlone", "aws", fake) + if err == nil { + t.Fatal("expected decode error, got nil") + } +} + +func TestUrlQueryEscape(t *testing.T) { + cases := []struct { + in, want string + }{ + {"a b c", "a%20b%20c"}, + {`"hello"`, "%22hello%22"}, + {"org:GoCodeAlone", "org%3AGoCodeAlone"}, + {`filename:wfctl.yaml org:X "name: workflow-plugin-aws"`, + `filename%3Awfctl.yaml%20org%3AX%20%22name%3A%20workflow-plugin-aws%22`}, + } + for _, tc := range cases { + got := urlQueryEscape(tc.in) + if got != tc.want { + t.Errorf("escape(%q)=%q want %q", tc.in, got, tc.want) + } + } +} + +func TestSearchEndpoint_BuildsExpectedURL(t *testing.T) { + var captured string + fake := func(ctx context.Context, endpoint string) ([]byte, error) { + captured = endpoint + return []byte(`{"items":[]}`), nil + } + _, _ = searchOrgForPluginPins(context.Background(), "GoCodeAlone", "twilio", fake) + wantSub := "filename%3Awfctl.yaml%20org%3AGoCodeAlone" + wantPlugin := "workflow-plugin-twilio" + if captured == "" { + t.Fatal("endpoint not captured") + } + if !strings.Contains(captured, wantSub) { + t.Errorf("endpoint missing %q: %s", wantSub, captured) + } + if !strings.Contains(captured, wantPlugin) { + t.Errorf("endpoint missing plugin name %q: %s", wantPlugin, captured) + } +}