From 8b238bec387ca05368cc7cf8d6cccf43e4fa76f0 Mon Sep 17 00:00:00 2001 From: Nabarun Pal Date: Sun, 24 May 2026 12:23:09 +0530 Subject: [PATCH] korg: add userinfo command for org and OWNERS lookup Adds `korg userinfo ...` to answer the recurring question this repo exists for: which Kubernetes orgs is a user in, with what role, and where do they appear in OWNERS files. Sources: - GitHub user API for company / profile - config//org.yaml (Members / Admins) for k8s org membership - cs.k8s.io (hound) for OWNERS / OWNERS_ALIASES references Behavior: - Honors GITHUB_TOKEN / GH_TOKEN to avoid the 60/hr unauth limit. GitHub and hound use separate http.Client instances, so the token is never sent to cs.k8s.io - Per-request context with a 30s timeout; SIGINT cancels in flight - Multi-user batches run concurrently (errgroup, bounded to 4) with output preserved in input order - One bad username does not kill the batch; per-user errors render in both text and JSON output, and the process exits non-zero - Hound failures degrade to a warning instead of dropping org info - `--output json` for scripting OWNERS matching: - Anchors the hound query on "- " and keeps a hit only when the YAML list item equals the username exactly, so prose mentions, commented-out entries and substring near-misses ("- alice-bot" for "alice") are all dropped. Surrounding quotes and trailing inline comments are stripped first, so `- "alice"` and "- alice # area expert" still match - Each surviving hit is confirmed against the file's real content via `gh api` to exclude emeritus-only entries, toggled by --verify-owners. A hit that cannot be fetched is kept with a warning rather than dropped - Results render as an aligned REPO / PATH / URL table; the URL uses /blob/HEAD/ so the link resolves to the default branch Tests: - Table-driven coverage of the OWNERS match filter (quoted values, inline comments, commented-out entries, substring near-misses), emeritus exclusion, and the fail-open path when a hit cannot be verified - Batch coverage for both output modes: input ordering, mixed success/failure runs, and the text renderer's company / orgs fallbacks - Asserts hound requests carry no Authorization header while GitHub requests stay authenticated Dependencies: - adds github.com/google/go-github/v88 for the user API - promotes golang.org/x/sync from indirect v0.2.0 to direct v0.8.0 for errgroup - go directive 1.22.4 -> 1.25.0 Signed-off-by: Nabarun Pal --- cmd/korg/korg.go | 51 ++- cmd/korg/userinfo.go | 463 +++++++++++++++++++++ cmd/korg/userinfo_test.go | 824 ++++++++++++++++++++++++++++++++++++++ go.mod | 6 +- go.sum | 16 +- 5 files changed, 1352 insertions(+), 8 deletions(-) create mode 100644 cmd/korg/userinfo.go create mode 100644 cmd/korg/userinfo_test.go diff --git a/cmd/korg/korg.go b/cmd/korg/korg.go index 41f1c83f4a..0b01addbcf 100644 --- a/cmd/korg/korg.go +++ b/cmd/korg/korg.go @@ -17,10 +17,13 @@ limitations under the License. package main import ( + "context" "fmt" "os" + "os/signal" "path/filepath" "strings" + "syscall" "github.com/spf13/cobra" ) @@ -58,6 +61,27 @@ Note: Removing from teams is currently unsupported. ` auditHelpText = "Audit GitHub org members" + + userinfoHelpText = ` +Gets k8s org membership, GitHub profile, and OWNERS file references for user(s). + +Honors GITHUB_TOKEN / GH_TOKEN for authenticated GitHub API access. + +A failed per-user lookup does not omit that user from the output: in text mode +it renders an ERROR block, and in JSON mode it appears with only "username" +and "error" populated. Check the exit code to detect any failures. + +By default, each OWNERS/OWNERS_ALIASES hit is confirmed against the file's +actual content (via the gh CLI) to exclude emeritus-only entries; this costs +one "gh api" call per hit and requires the gh CLI to be installed and +authenticated. Pass --verify-owners=false to skip this and use hound's raw +search hits as-is. + + korg userinfo + korg userinfo ... + korg userinfo --output json + korg userinfo --verify-owners=false + ` ) type Options struct { @@ -222,7 +246,32 @@ func main() { rootCmd.AddCommand(removeCmd) rootCmd.AddCommand(auditCmd) - if err := rootCmd.Execute(); err != nil { + var ( + outputFormat string + verifyOwners bool + ) + userInfoCmd := &cobra.Command{ + Use: "userinfo", + Short: "Get information about user(s)", + Long: userinfoHelpText, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + switch outputFormat { + case "text", "json": + default: + return fmt.Errorf("invalid --output %q (want: text, json)", outputFormat) + } + return runUserinfo(cmd.Context(), o.RepoRoot, args, outputFormat == "json", verifyOwners, cmd.OutOrStdout()) + }, + } + userInfoCmd.Flags().StringVarP(&outputFormat, "output", "o", "text", "output format: text|json") + userInfoCmd.Flags().BoolVar(&verifyOwners, "verify-owners", true, "confirm each OWNERS/OWNERS_ALIASES hit via the gh CLI and exclude emeritus-only entries; requires gh to be installed and authenticated") + rootCmd.AddCommand(userInfoCmd) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := rootCmd.ExecuteContext(ctx); err != nil { os.Exit(1) } } diff --git a/cmd/korg/userinfo.go b/cmd/korg/userinfo.go new file mode 100644 index 0000000000..0a7603d0b8 --- /dev/null +++ b/cmd/korg/userinfo.go @@ -0,0 +1,463 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/google/go-github/v88/github" + houndclient "github.com/hound-search/hound/client" + houndindex "github.com/hound-search/hound/index" + "golang.org/x/sync/errgroup" + "sigs.k8s.io/prow/pkg/config/org" + "sigs.k8s.io/yaml" +) + +const ( + houndSearchURL = "https://cs.k8s.io/api/v1/search" + defaultHTTPTimeout = 30 * time.Second + userinfoConcurrency = 4 + + // unknownLookupError is reported when a lookup yields neither a result nor an + // error. findUserDetails never returns that combination today; both output modes + // carry the fallback so that if the invariant ever breaks, the user is still + // reported instead of silently disappearing. + unknownLookupError = "unknown error" +) + +// Test-only overrides. Empty means use defaults / api.github.com. +var ( + houndSearchURLOverride string + ghBaseURLOverride string +) + +func houndURL() string { + if houndSearchURLOverride != "" { + return houndSearchURLOverride + } + return houndSearchURL +} + +type OrgMembership struct { + Org string `json:"org"` + Role string `json:"role"` // "member" or "admin" +} + +type OwnerFile struct { + Repo string `json:"repo"` + Path string `json:"path"` + URL string `json:"url"` +} + +type UserDetails struct { + Username string `json:"username"` + Company string `json:"company,omitempty"` + Orgs []OrgMembership `json:"orgs"` + OwnerFiles []OwnerFile `json:"owner_files,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Error string `json:"error,omitempty"` +} + +// findUserDetails gathers GitHub profile, k8s org membership, and OWNERS file references for a username. +// GitHub or k/org config failures are fatal (returned as error). Hound failures are non-fatal warnings. +// When verifyOwners is true, each OWNERS hit is additionally confirmed against the file's actual +// content (via the gh CLI) to exclude emeritus-only entries; this costs one gh api call per hit. +func findUserDetails(ctx context.Context, gh *github.Client, hc *http.Client, configs map[string]*org.Config, username string, verifyOwners bool) (*UserDetails, error) { + info := &UserDetails{Username: username} + + u, _, err := gh.Users.Get(ctx, username) + if err != nil { + return nil, fmt.Errorf("github user %q: %w", username, err) + } + if u.Company != nil { + info.Company = *u.Company + } + + info.Orgs = findOrgMembership(configs, username) + + hits, err := searchOwnerFiles(ctx, hc, username) + switch { + case err != nil: + info.Warnings = append(info.Warnings, fmt.Sprintf("OWNERS lookup failed: %v", err)) + case !verifyOwners: + info.OwnerFiles = hits + default: + info.OwnerFiles = make([]OwnerFile, 0, len(hits)) + for _, hit := range hits { + content, err := fetchOwnerFileContent(ctx, hit.Repo, hit.Path) + if err != nil { + // Fail open: we can't confirm the hit, but hound already found it, so keep it. + info.Warnings = append(info.Warnings, fmt.Sprintf("could not verify %s/%s: %v", hit.Repo, hit.Path, err)) + info.OwnerFiles = append(info.OwnerFiles, hit) + continue + } + if isActiveOwner(content, hit.Path, username) { + info.OwnerFiles = append(info.OwnerFiles, hit) + } + } + } + + return info, nil +} + +func findOrgMembership(configs map[string]*org.Config, username string) []OrgMembership { + out := []OrgMembership{} + orgNames := make([]string, 0, len(configs)) + for name := range configs { + orgNames = append(orgNames, name) + } + sort.Strings(orgNames) + + for _, name := range orgNames { + cfg := configs[name] + switch { + case stringInSliceCaseAgnostic(cfg.Admins, username): + out = append(out, OrgMembership{Org: name, Role: "admin"}) + case stringInSliceCaseAgnostic(cfg.Members, username): + out = append(out, OrgMembership{Org: name, Role: "member"}) + } + } + return out +} + +func loadOrgConfigs(repoRoot string, orgs []string) (map[string]*org.Config, error) { + out := make(map[string]*org.Config, len(orgs)) + for _, name := range orgs { + path := filepath.Join(repoRoot, fmt.Sprintf(orgConfigPathFormat, name)) + cfg, err := readConfig(path) + if err != nil { + return nil, fmt.Errorf("loading org config %s: %w", name, err) + } + out[name] = cfg + } + return out, nil +} + +func ownerFileURL(repo, path string) string { + return fmt.Sprintf("https://github.com/%s/blob/HEAD/%s", repo, path) +} + +func searchOwnerFiles(ctx context.Context, hc *http.Client, username string) ([]OwnerFile, error) { + q := url.Values{} + // OWNERS/OWNERS_ALIASES entries are always YAML list items, so anchor the + // search on "- " to skip files that merely mention the name in + // prose or as a substring of another username. + q.Set("q", "- "+username) + q.Set("literal", "true") + q.Set("repos", "*") + q.Set("rng", ":20") + q.Set("files", "OWNERS(_ALIASES)?") + q.Set("excludeFiles", "vendor/") + q.Set("i", "true") + q.Set("stats", "true") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, houndURL()+"?"+q.Encode(), nil) + if err != nil { + return nil, err + } + + resp, err := hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode/100 != 2 { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + return nil, fmt.Errorf("hound %s: %s", resp.Status, bytes.TrimSpace(snippet)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var r houndclient.Response + if err := json.Unmarshal(body, &r); err != nil { + return nil, fmt.Errorf("decoding hound response: %w", err) + } + + out := []OwnerFile{} + for repo, matches := range r.Results { + if matches == nil { + continue + } + for _, fm := range matches.Matches { + if !hasRealMatch(fm.Matches, username) { + continue + } + out = append(out, OwnerFile{ + Repo: repo, + Path: fm.Filename, + URL: ownerFileURL(repo, fm.Filename), + }) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Repo != out[j].Repo { + return out[i].Repo < out[j].Repo + } + return out[i].Path < out[j].Path + }) + return out, nil +} + +// hasRealMatch reports whether any hit line is an uncommented YAML list item whose +// value is exactly username, rather than a comment or a substring match like +// "- alice-bot" or "- alicexyz" (hound's search is substring-based, so those also +// match a query for "- alice"). Trailing inline comments and surrounding quotes are +// stripped, so "- alice", `- "alice"` and "- alice # area expert" all match. +func hasRealMatch(matches []*houndindex.Match, username string) bool { + for _, m := range matches { + line := strings.TrimSpace(m.Line) + rest, ok := strings.CutPrefix(line, "-") + if !ok { + continue + } + rest = strings.TrimSpace(rest) + // Drop any trailing inline comment before comparing; usernames cannot contain "#". + if idx := strings.Index(rest, "#"); idx >= 0 { + rest = strings.TrimSpace(rest[:idx]) + } + rest = strings.Trim(rest, `"'`) + if strings.EqualFold(rest, username) { + return true + } + } + return false +} + +// ownersFileContent models the fields of an OWNERS file relevant to deciding +// whether a username is a current (non-emeritus) approver or reviewer. +type ownersFileContent struct { + Approvers []string `json:"approvers,omitempty"` + Reviewers []string `json:"reviewers,omitempty"` + EmeritusApprovers []string `json:"emeritus_approvers,omitempty"` + EmeritusReviewers []string `json:"emeritus_reviewers,omitempty"` + Filters map[string]ownersFilterRule `json:"filters,omitempty"` +} + +type ownersFilterRule struct { + Approvers []string `json:"approvers,omitempty"` + Reviewers []string `json:"reviewers,omitempty"` +} + +// ownersAliasesContent models an OWNERS_ALIASES file. +type ownersAliasesContent struct { + Aliases map[string][]string `json:"aliases,omitempty"` +} + +// runGHCLI invokes the gh CLI and returns its stdout. Overridden in tests. +var runGHCLI = func(ctx context.Context, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "gh", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) + } + return stdout.Bytes(), nil +} + +// fetchOwnerFileContent fetches the raw contents of an OWNERS or OWNERS_ALIASES file at repo HEAD +// via the gh CLI, so it reuses the caller's existing `gh auth login` session. +func fetchOwnerFileContent(ctx context.Context, repo, path string) ([]byte, error) { + endpoint := fmt.Sprintf("repos/%s/contents/%s", repo, escapeGitHubPath(path)) + return runGHCLI(ctx, "api", endpoint, "-H", "Accept: application/vnd.github.raw") +} + +// escapeGitHubPath percent-encodes each path segment while preserving "/" separators. +func escapeGitHubPath(path string) string { + parts := strings.Split(path, "/") + for i, p := range parts { + parts[i] = url.PathEscape(p) + } + return strings.Join(parts, "/") +} + +// isActiveOwner reports whether username is a current (non-emeritus) approver, +// reviewer, or alias member per the given OWNERS/OWNERS_ALIASES file content. +// It fails open (returns true) if content can't be parsed, since hound already +// found the hit and we'd rather over- than under-report. +func isActiveOwner(content []byte, path, username string) bool { + if filepath.Base(path) == "OWNERS_ALIASES" { + var a ownersAliasesContent + if err := yaml.Unmarshal(content, &a); err != nil { + return true + } + for _, members := range a.Aliases { + if stringInSliceCaseAgnostic(members, username) { + return true + } + } + return false + } + + var o ownersFileContent + if err := yaml.Unmarshal(content, &o); err != nil { + return true + } + if stringInSliceCaseAgnostic(o.Approvers, username) || stringInSliceCaseAgnostic(o.Reviewers, username) { + return true + } + for _, f := range o.Filters { + if stringInSliceCaseAgnostic(f.Approvers, username) || stringInSliceCaseAgnostic(f.Reviewers, username) { + return true + } + } + return false +} + +// renderText writes a human-readable form of UserDetails to w. +func (u *UserDetails) renderText(w io.Writer) { + fmt.Fprintf(w, "\n=== %s\n", u.Username) + if u.Company != "" { + fmt.Fprintln(w, "Company:", u.Company) + } else { + fmt.Fprintln(w, "Company: **Not Found**") + } + + fmt.Fprintln(w, "Orgs:") + if len(u.Orgs) == 0 { + fmt.Fprintln(w, " (none)") + } + for _, m := range u.Orgs { + fmt.Fprintf(w, " %s (%s)\n", m.Org, m.Role) + } + + if len(u.OwnerFiles) > 0 { + fmt.Fprintln(w, "Owner Files:") + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, " REPO\tPATH\tURL") + for _, of := range u.OwnerFiles { + fmt.Fprintf(tw, " %s\t%s\t%s\n", of.Repo, of.Path, of.URL) + } + tw.Flush() + } + + for _, warn := range u.Warnings { + fmt.Fprintln(w, "Warning:", warn) + } +} + +// newGitHubClient returns a GitHub client authenticated via GITHUB_TOKEN or GH_TOKEN if set. +func newGitHubClient(httpClient *http.Client) (*github.Client, error) { + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + token = os.Getenv("GH_TOKEN") + } + opts := []github.ClientOptionsFunc{} + if httpClient != nil { + opts = append(opts, github.WithHTTPClient(httpClient)) + } + if token != "" { + opts = append(opts, github.WithAuthToken(token)) + } + if ghBaseURLOverride != "" { + opts = append(opts, github.WithEnterpriseURLs(ghBaseURLOverride, ghBaseURLOverride)) + } + return github.NewClient(opts...) +} + +// runUserinfo fetches info for every username concurrently and writes ordered output to w. +// Returns a joined error of all per-user failures; users without errors still render. +func runUserinfo(ctx context.Context, repoRoot string, usernames []string, outputJSON, verifyOwners bool, w io.Writer) error { + configs, err := loadOrgConfigs(repoRoot, validOrgs) + if err != nil { + return err + } + + // Use a dedicated http.Client for GitHub and a separate one for hound. + // newGitHubClient layers a token-injecting RoundTripper onto the client it is + // handed, so keeping hound on its own client guarantees the GitHub token is + // never sent to cs.k8s.io, independent of go-github's internal copy semantics. + ghHTTP := &http.Client{Timeout: defaultHTTPTimeout} + houndHTTP := &http.Client{Timeout: defaultHTTPTimeout} + + gh, err := newGitHubClient(ghHTTP) + if err != nil { + return fmt.Errorf("building github client: %w", err) + } + + results := make([]*UserDetails, len(usernames)) + errs := make([]error, len(usernames)) + + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(userinfoConcurrency) + for i, name := range usernames { + g.Go(func() error { + info, err := findUserDetails(gctx, gh, houndHTTP, configs, name, verifyOwners) + if err != nil { + errs[i] = err + return nil + } + results[i] = info + return nil + }) + } + if err := g.Wait(); err != nil { + return fmt.Errorf("unexpected errgroup error: %w", err) + } + + if outputJSON { + out := make([]*UserDetails, len(results)) + for i, r := range results { + switch { + case r != nil: + out[i] = r + case errs[i] != nil: + out[i] = &UserDetails{Username: usernames[i], Error: errs[i].Error()} + default: + out[i] = &UserDetails{Username: usernames[i], Error: unknownLookupError} + } + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(out); err != nil { + return err + } + } else { + // Mirrors the JSON branch above, including the defensive fallback, so a + // (nil, nil) result is never silently omitted from either output mode. + for i, r := range results { + switch { + case r != nil: + r.renderText(w) + case errs[i] != nil: + fmt.Fprintf(w, "\n=== %s\nERROR: %v\n", usernames[i], errs[i]) + default: + fmt.Fprintf(w, "\n=== %s\nERROR: %s\n", usernames[i], unknownLookupError) + } + } + } + + return errors.Join(errs...) +} diff --git a/cmd/korg/userinfo_test.go b/cmd/korg/userinfo_test.go new file mode 100644 index 0000000000..232ea263d6 --- /dev/null +++ b/cmd/korg/userinfo_test.go @@ -0,0 +1,824 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/google/go-github/v88/github" + houndindex "github.com/hound-search/hound/index" + "sigs.k8s.io/prow/pkg/config/org" +) + +// stubGHCLI overrides runGHCLI for the duration of t, restoring it on cleanup. +func stubGHCLI(t *testing.T, fn func(ctx context.Context, args ...string) ([]byte, error)) { + t.Helper() + orig := runGHCLI + runGHCLI = fn + t.Cleanup(func() { runGHCLI = orig }) +} + +func TestFindOrgMembership(t *testing.T) { + configs := map[string]*org.Config{ + "kubernetes": { + Members: []string{"alice", "BOB"}, + Admins: []string{"carol"}, + }, + "kubernetes-sigs": { + Members: []string{"bob"}, + Admins: []string{"alice"}, + }, + "kubernetes-csi": {}, + } + + cases := map[string][]OrgMembership{ + "alice": {{Org: "kubernetes", Role: "member"}, {Org: "kubernetes-sigs", Role: "admin"}}, + "bob": {{Org: "kubernetes", Role: "member"}, {Org: "kubernetes-sigs", Role: "member"}}, + "BoB": {{Org: "kubernetes", Role: "member"}, {Org: "kubernetes-sigs", Role: "member"}}, + "carol": {{Org: "kubernetes", Role: "admin"}}, + "nobody": {}, + } + + for user, want := range cases { + got := findOrgMembership(configs, user) + if !reflect.DeepEqual(got, want) { + t.Errorf("user %q: got %+v, want %+v", user, got, want) + } + } +} + +func TestLoadOrgConfigs(t *testing.T) { + root := t.TempDir() + for _, name := range []string{"kubernetes", "kubernetes-sigs"} { + dir := filepath.Join(root, "config", name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + yaml := fmt.Sprintf("members:\n- user-%s\nadmins:\n- admin-%s\n", name, name) + if err := os.WriteFile(filepath.Join(dir, "org.yaml"), []byte(yaml), 0o644); err != nil { + t.Fatal(err) + } + } + + configs, err := loadOrgConfigs(root, []string{"kubernetes", "kubernetes-sigs"}) + if err != nil { + t.Fatalf("loadOrgConfigs: %v", err) + } + if got := configs["kubernetes"].Members[0]; got != "user-kubernetes" { + t.Errorf("kubernetes member: got %q", got) + } + if got := configs["kubernetes-sigs"].Admins[0]; got != "admin-kubernetes-sigs" { + t.Errorf("kubernetes-sigs admin: got %q", got) + } + + if _, err := loadOrgConfigs(root, []string{"kubernetes-csi"}); err == nil { + t.Error("expected error for missing org config") + } +} + +func TestSearchOwnerFiles(t *testing.T) { + const happyBody = `{ + "Results": { + "kubernetes/kubernetes": { + "Matches": [ + {"Filename": "OWNERS", "Matches": [{"Line": " - alice", "LineNumber": 3}]}, + {"Filename": "pkg/foo/OWNERS", "Matches": [{"Line": " - alice", "LineNumber": 5}]}, + {"Filename": "commented/OWNERS", "Matches": [{"Line": " # - alice", "LineNumber": 2}]}, + {"Filename": "substring/OWNERS", "Matches": [{"Line": " - alice-bot", "LineNumber": 7}]} + ] + } + } + }` + + tests := []struct { + name string + status int + body string + wantErr bool + wantRepo string + wantLen int + }{ + {name: "happy", status: 200, body: happyBody, wantRepo: "kubernetes/kubernetes", wantLen: 2}, + {name: "500", status: 500, body: "boom", wantErr: true}, + {name: "bad json", status: 200, body: "not json", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var seenURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenURL = r.URL.String() + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer srv.Close() + + origURL := houndSearchURLOverride + houndSearchURLOverride = srv.URL + defer func() { houndSearchURLOverride = origURL }() + + out, err := searchOwnerFiles(context.Background(), srv.Client(), "alice") + if tc.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + parsed, perr := url.Parse(seenURL) + if perr != nil { + t.Fatalf("parsing seen URL: %v", perr) + } + if got := parsed.Query().Get("q"); got != "- alice" { + t.Errorf("query: got %q, want %q", got, "- alice") + } + if got := len(out); got != tc.wantLen { + t.Errorf("got %d entries, want %d", got, tc.wantLen) + } + for _, of := range out { + if of.Repo != tc.wantRepo { + t.Errorf("repo: got %q want %q", of.Repo, tc.wantRepo) + } + wantURL := "https://github.com/" + of.Repo + "/blob/HEAD/" + of.Path + if of.URL != wantURL { + t.Errorf("url: got %q want %q", of.URL, wantURL) + } + } + }) + } +} + +func TestHasRealMatch(t *testing.T) { + tests := []struct { + name string + line string + want bool + }{ + {name: "plain list item", line: "- alice", want: true}, + {name: "indented list item", line: " - alice", want: true}, + {name: "no space after dash", line: "-alice", want: true}, + {name: "double quoted", line: `- "alice"`, want: true}, + {name: "single quoted", line: "- 'alice'", want: true}, + {name: "case insensitive", line: "- ALICE", want: true}, + {name: "inline comment", line: "- alice # area expert", want: true}, + {name: "quoted with inline comment", line: `- "alice" # area expert`, want: true}, + {name: "commented out entry", line: "# - alice", want: false}, + {name: "indented commented out entry", line: " #- alice", want: false}, + {name: "substring with suffix", line: "- alice-bot", want: false}, + {name: "substring without separator", line: "- alicexyz", want: false}, + {name: "quoted substring", line: `- "alice-bot"`, want: false}, + {name: "prose mention", line: "alice is an approver", want: false}, + {name: "not a list item", line: "approvers: alice", want: false}, + {name: "different user", line: "- bob", want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := hasRealMatch([]*houndindex.Match{{Line: tc.line}}, "alice") + if got != tc.want { + t.Errorf("hasRealMatch(%q) = %v, want %v", tc.line, got, tc.want) + } + }) + } + + t.Run("one real match among noise", func(t *testing.T) { + matches := []*houndindex.Match{ + {Line: "# - alice"}, + {Line: "- alice-bot"}, + {Line: " - alice"}, + } + if !hasRealMatch(matches, "alice") { + t.Error("expected a real match to be found among commented and substring lines") + } + }) + + t.Run("no matches", func(t *testing.T) { + if hasRealMatch(nil, "alice") { + t.Error("expected no match for an empty match list") + } + }) +} + +func TestFindUserDetails(t *testing.T) { + stubGHCLI(t, func(ctx context.Context, args ...string) ([]byte, error) { + if len(args) == 4 && args[0] == "api" && args[1] == "repos/k/k/contents/OWNERS" { + return []byte("approvers:\n- alice\n"), nil + } + return nil, fmt.Errorf("unexpected gh invocation: %v", args) + }) + + ghHits := 0 + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ghHits++ + switch { + case strings.HasSuffix(r.URL.Path, "/users/alice"): + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"login":"alice","company":"Acme Inc"}`) + case strings.HasSuffix(r.URL.Path, "/users/ghost"): + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + default: + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer gh.Close() + + hound := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"Results":{"k/k":{"Matches":[{"Filename":"OWNERS","Matches":[{"Line":"- alice","LineNumber":2}]}]}}}`) + })) + defer hound.Close() + + origURL := houndSearchURLOverride + houndSearchURLOverride = hound.URL + defer func() { houndSearchURLOverride = origURL }() + + client, err := github.NewClient(github.WithEnterpriseURLs(gh.URL+"/", gh.URL+"/")) + if err != nil { + t.Fatalf("github client: %v", err) + } + + configs := map[string]*org.Config{ + "kubernetes": {Members: []string{"alice"}}, + } + + t.Run("happy", func(t *testing.T) { + info, err := findUserDetails(context.Background(), client, hound.Client(), configs, "alice", true) + if err != nil { + t.Fatalf("findUserDetails: %v", err) + } + if info.Company != "Acme Inc" { + t.Errorf("company: got %q", info.Company) + } + if len(info.Orgs) != 1 || info.Orgs[0].Org != "kubernetes" { + t.Errorf("orgs: %+v", info.Orgs) + } + if len(info.OwnerFiles) != 1 || info.OwnerFiles[0].Repo != "k/k" || info.OwnerFiles[0].Path != "OWNERS" { + t.Errorf("owner files: %+v", info.OwnerFiles) + } + if info.OwnerFiles[0].URL != "https://github.com/k/k/blob/HEAD/OWNERS" { + t.Errorf("url: %q", info.OwnerFiles[0].URL) + } + }) + + t.Run("404 user is fatal per-user", func(t *testing.T) { + _, err := findUserDetails(context.Background(), client, hound.Client(), configs, "ghost", true) + if err == nil { + t.Fatal("expected error for 404 user") + } + }) + + t.Run("hound failure is non-fatal", func(t *testing.T) { + badHound := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer badHound.Close() + origHoundURL := houndSearchURLOverride + houndSearchURLOverride = badHound.URL + defer func() { houndSearchURLOverride = origHoundURL }() + + info, err := findUserDetails(context.Background(), client, badHound.Client(), configs, "alice", true) + if err != nil { + t.Fatalf("hound failure should not fail user lookup: %v", err) + } + if len(info.Warnings) == 0 { + t.Error("expected warning about hound failure") + } + if info.Company != "Acme Inc" { + t.Errorf("company still expected: %q", info.Company) + } + }) + + t.Run("verifyOwners false skips gh CLI verification", func(t *testing.T) { + calls := 0 + stubGHCLI(t, func(ctx context.Context, args ...string) ([]byte, error) { + calls++ + return nil, fmt.Errorf("gh CLI should not be invoked when verifyOwners is false") + }) + + info, err := findUserDetails(context.Background(), client, hound.Client(), configs, "alice", false) + if err != nil { + t.Fatalf("findUserDetails: %v", err) + } + if calls != 0 { + t.Errorf("expected 0 gh CLI invocations, got %d", calls) + } + if len(info.OwnerFiles) != 1 || info.OwnerFiles[0].Path != "OWNERS" { + t.Errorf("expected raw hound hit to pass through unverified, got %+v", info.OwnerFiles) + } + }) +} + +func TestIsActiveOwner(t *testing.T) { + tests := []struct { + name string + path string + content string + want bool + }{ + { + name: "current approver", + path: "OWNERS", + content: "approvers:\n- alice\n", + want: true, + }, + { + name: "current reviewer", + path: "OWNERS", + content: "reviewers:\n- alice\n", + want: true, + }, + { + name: "filter approver", + path: "OWNERS", + content: "filters:\n \".*\\\\.go$\":\n approvers:\n - alice\n", + want: true, + }, + { + name: "emeritus approver only", + path: "OWNERS", + content: "emeritus_approvers:\n- alice\n", + want: false, + }, + { + name: "emeritus reviewer only", + path: "OWNERS", + content: "emeritus_reviewers:\n- alice\n", + want: false, + }, + { + name: "not present at all", + path: "OWNERS", + content: "approvers:\n- bob\n", + want: false, + }, + { + name: "alias member", + path: "OWNERS_ALIASES", + content: "aliases:\n sig-foo-approvers:\n - alice\n", + want: true, + }, + { + name: "not in any alias", + path: "OWNERS_ALIASES", + content: "aliases:\n sig-foo-approvers:\n - bob\n", + want: false, + }, + { + name: "unparseable content fails open", + path: "OWNERS", + content: "not: [valid", + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := isActiveOwner([]byte(tc.content), tc.path, "alice"); got != tc.want { + t.Errorf("isActiveOwner(%q, %q) = %v, want %v", tc.path, tc.content, got, tc.want) + } + }) + } +} + +func TestFindUserDetailsExcludesEmeritusOwner(t *testing.T) { + stubGHCLI(t, func(ctx context.Context, args ...string) ([]byte, error) { + if len(args) == 4 && args[0] == "api" && args[1] == "repos/k/k/contents/OWNERS" { + return []byte("emeritus_approvers:\n- alice\n"), nil + } + return nil, fmt.Errorf("unexpected gh invocation: %v", args) + }) + + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/users/alice"): + fmt.Fprint(w, `{"login":"alice"}`) + default: + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer gh.Close() + + hound := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"Results":{"k/k":{"Matches":[{"Filename":"OWNERS","Matches":[{"Line":"- alice","LineNumber":2}]}]}}}`) + })) + defer hound.Close() + + origURL := houndSearchURLOverride + houndSearchURLOverride = hound.URL + defer func() { houndSearchURLOverride = origURL }() + + client, err := github.NewClient(github.WithEnterpriseURLs(gh.URL+"/", gh.URL+"/")) + if err != nil { + t.Fatalf("github client: %v", err) + } + + info, err := findUserDetails(context.Background(), client, hound.Client(), map[string]*org.Config{}, "alice", true) + if err != nil { + t.Fatalf("findUserDetails: %v", err) + } + if len(info.OwnerFiles) != 0 { + t.Errorf("expected emeritus-only hit to be excluded, got %+v", info.OwnerFiles) + } +} + +// TestFindUserDetailsFailsOpenOnVerificationError pins the deliberate fail-open +// behavior: when an OWNERS hit cannot be verified, the hit is kept and a warning is +// recorded rather than the hit being dropped. +func TestFindUserDetailsFailsOpenOnVerificationError(t *testing.T) { + stubGHCLI(t, func(ctx context.Context, args ...string) ([]byte, error) { + if len(args) > 1 && strings.Contains(args[1], "pkg/foo/OWNERS") { + return nil, fmt.Errorf("gh api: rate limited") + } + return []byte("approvers:\n- alice\n"), nil + }) + + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/users/alice") { + fmt.Fprint(w, `{"login":"alice"}`) + return + } + w.WriteHeader(http.StatusInternalServerError) + })) + defer gh.Close() + + hound := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"Results":{"k/k":{"Matches":[ + {"Filename":"OWNERS","Matches":[{"Line":"- alice","LineNumber":2}]}, + {"Filename":"pkg/foo/OWNERS","Matches":[{"Line":"- alice","LineNumber":4}]} + ]}}}`) + })) + defer hound.Close() + + origURL := houndSearchURLOverride + houndSearchURLOverride = hound.URL + defer func() { houndSearchURLOverride = origURL }() + + client, err := github.NewClient(github.WithEnterpriseURLs(gh.URL+"/", gh.URL+"/")) + if err != nil { + t.Fatalf("github client: %v", err) + } + + info, err := findUserDetails(context.Background(), client, hound.Client(), map[string]*org.Config{}, "alice", true) + if err != nil { + t.Fatalf("verification failure should not fail the lookup: %v", err) + } + + if len(info.OwnerFiles) != 2 { + t.Errorf("expected both hits preserved when one cannot be verified, got %+v", info.OwnerFiles) + } + if len(info.Warnings) != 1 { + t.Fatalf("expected exactly one warning, got %+v", info.Warnings) + } + if !strings.Contains(info.Warnings[0], "could not verify") || !strings.Contains(info.Warnings[0], "pkg/foo/OWNERS") { + t.Errorf("warning should name the unverifiable file, got %q", info.Warnings[0]) + } +} + +// TestRunUserinfoDoesNotSendTokenToHound guards the client separation in runUserinfo: +// the GitHub token must never travel to cs.k8s.io, while GitHub requests stay authenticated. +func TestRunUserinfoDoesNotSendTokenToHound(t *testing.T) { + const token = "s3cr3t-token" + t.Setenv("GITHUB_TOKEN", token) + t.Setenv("GH_TOKEN", "") + + var ghAuth, houndAuth string + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ghAuth = r.Header.Get("Authorization") + if strings.HasSuffix(r.URL.Path, "/users/alice") { + fmt.Fprint(w, `{"login":"alice","company":"Acme"}`) + return + } + w.WriteHeader(http.StatusInternalServerError) + })) + defer gh.Close() + + hound := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + houndAuth = r.Header.Get("Authorization") + fmt.Fprint(w, `{"Results":{}}`) + })) + defer hound.Close() + + houndSearchURLOverride = hound.URL + ghBaseURLOverride = gh.URL + "/" + defer func() { houndSearchURLOverride = ""; ghBaseURLOverride = "" }() + + root := t.TempDir() + for _, name := range validOrgs { + dir := filepath.Join(root, "config", name) + _ = os.MkdirAll(dir, 0o755) + _ = os.WriteFile(filepath.Join(dir, "org.yaml"), []byte("members: []\n"), 0o644) + } + + var buf bytes.Buffer + if err := runUserinfo(context.Background(), root, []string{"alice"}, true, false, &buf); err != nil { + t.Fatalf("runUserinfo: %v", err) + } + + if houndAuth != "" { + t.Errorf("hound request carried an Authorization header (%q); the GitHub token must not reach cs.k8s.io", houndAuth) + } + if !strings.Contains(ghAuth, token) { + t.Errorf("expected the GitHub request to carry the token, got Authorization %q", ghAuth) + } +} + +func TestRunUserinfoJSON(t *testing.T) { + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/users/alice") { + fmt.Fprint(w, `{"login":"alice","company":"Acme"}`) + return + } + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + })) + defer gh.Close() + + hound := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"Results":{}}`) + })) + defer hound.Close() + + houndSearchURLOverride = hound.URL + ghBaseURLOverride = gh.URL + "/" + defer func() { houndSearchURLOverride = ""; ghBaseURLOverride = "" }() + + root := t.TempDir() + for _, name := range validOrgs { + dir := filepath.Join(root, "config", name) + _ = os.MkdirAll(dir, 0o755) + _ = os.WriteFile(filepath.Join(dir, "org.yaml"), []byte("members:\n- alice\n"), 0o644) + } + + var buf bytes.Buffer + err := runUserinfo(context.Background(), root, []string{"alice"}, true, true, &buf) + if err != nil { + t.Fatalf("runUserinfo: %v", err) + } + + var got []*UserDetails + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("decode: %v\nraw: %s", err, buf.String()) + } + if len(got) != 1 || got[0].Username != "alice" || got[0].Company != "Acme" { + t.Errorf("unexpected: %+v", got) + } + if len(got[0].Orgs) != len(validOrgs) { + t.Errorf("expected membership in %d orgs, got %d", len(validOrgs), len(got[0].Orgs)) + } +} + +func TestRunUserinfoJSONIncludesFailedUsers(t *testing.T) { + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + })) + defer gh.Close() + + hound := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"Results":{}}`) + })) + defer hound.Close() + + houndSearchURLOverride = hound.URL + ghBaseURLOverride = gh.URL + "/" + defer func() { houndSearchURLOverride = ""; ghBaseURLOverride = "" }() + + root := t.TempDir() + for _, name := range validOrgs { + dir := filepath.Join(root, "config", name) + _ = os.MkdirAll(dir, 0o755) + _ = os.WriteFile(filepath.Join(dir, "org.yaml"), []byte("members: []\n"), 0o644) + } + + var buf bytes.Buffer + err := runUserinfo(context.Background(), root, []string{"ghost"}, true, true, &buf) + if err == nil { + t.Fatal("expected error for missing user") + } + + var got []*UserDetails + if unmarshalErr := json.Unmarshal(buf.Bytes(), &got); unmarshalErr != nil { + t.Fatalf("decode: %v\nraw: %s", unmarshalErr, buf.String()) + } + if len(got) != 1 || got[0].Username != "ghost" { + t.Fatalf("unexpected: %+v", got) + } + if got[0].Error == "" { + t.Error("expected non-empty error for failed lookup") + } +} + +func TestRunUserinfoText(t *testing.T) { + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/users/alice") { + fmt.Fprint(w, `{"login":"alice","company":"Acme"}`) + return + } + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + })) + defer gh.Close() + + hound := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"Results":{"k/k":{"Matches":[{"Filename":"OWNERS","Matches":[{"Line":"- alice","LineNumber":2}]}]}}}`) + })) + defer hound.Close() + + houndSearchURLOverride = hound.URL + ghBaseURLOverride = gh.URL + "/" + defer func() { houndSearchURLOverride = ""; ghBaseURLOverride = "" }() + + stubGHCLI(t, func(ctx context.Context, args ...string) ([]byte, error) { + return []byte("approvers:\n- alice\n"), nil + }) + + root := t.TempDir() + for _, name := range validOrgs { + dir := filepath.Join(root, "config", name) + _ = os.MkdirAll(dir, 0o755) + body := "members: []\n" + if name == "kubernetes" { + body = "members:\n- alice\n" + } + _ = os.WriteFile(filepath.Join(dir, "org.yaml"), []byte(body), 0o644) + } + + var buf bytes.Buffer + if err := runUserinfo(context.Background(), root, []string{"alice"}, false, true, &buf); err != nil { + t.Fatalf("runUserinfo: %v", err) + } + + out := buf.String() + for _, want := range []string{ + "=== alice", + "Company: Acme", + "Orgs:", + " kubernetes (member)", + "Owner Files:", + "REPO", + "PATH", + "URL", + "k/k", + "OWNERS", + "https://github.com/k/k/blob/HEAD/OWNERS", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q, got:\n%s", want, out) + } + } +} + +// TestRunUserinfoTextMixedBatch covers the text-mode branches the happy-path test +// misses: the ERROR block for a failed user, the "**Not Found**" company fallback, +// the "(none)" placeholder for a user with no org memberships, and output ordering. +func TestRunUserinfoTextMixedBatch(t *testing.T) { + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/users/alice"): + // No company field, so the "**Not Found**" fallback renders. + fmt.Fprint(w, `{"login":"alice"}`) + case strings.HasSuffix(r.URL.Path, "/users/ghost"): + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + default: + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer gh.Close() + + hound := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"Results":{}}`) + })) + defer hound.Close() + + houndSearchURLOverride = hound.URL + ghBaseURLOverride = gh.URL + "/" + defer func() { houndSearchURLOverride = ""; ghBaseURLOverride = "" }() + + root := t.TempDir() + for _, name := range validOrgs { + dir := filepath.Join(root, "config", name) + _ = os.MkdirAll(dir, 0o755) + _ = os.WriteFile(filepath.Join(dir, "org.yaml"), []byte("members: []\n"), 0o644) + } + + var buf bytes.Buffer + err := runUserinfo(context.Background(), root, []string{"alice", "ghost"}, false, true, &buf) + if err == nil { + t.Fatal("expected an error for the failed user") + } + + out := buf.String() + for _, want := range []string{ + "=== alice", + "Company: **Not Found**", + "Orgs:", + " (none)", + "=== ghost", + "ERROR:", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q, got:\n%s", want, out) + } + } + + // No hound hits, so the Owner Files table must be omitted entirely. + if strings.Contains(out, "Owner Files:") { + t.Errorf("expected no Owner Files section without hits, got:\n%s", out) + } + + // Failed users render after the successful ones they follow in the input. + if ai, gi := strings.Index(out, "=== alice"), strings.Index(out, "=== ghost"); ai > gi { + t.Errorf("expected alice before ghost, got:\n%s", out) + } +} + +func TestRunUserinfoJSONMixedBatch(t *testing.T) { + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/users/alice"): + fmt.Fprint(w, `{"login":"alice","company":"Acme"}`) + case strings.HasSuffix(r.URL.Path, "/users/bob"): + fmt.Fprint(w, `{"login":"bob","company":"Corp"}`) + case strings.HasSuffix(r.URL.Path, "/users/ghost"): + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + default: + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer gh.Close() + + hound := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"Results":{}}`) + })) + defer hound.Close() + + houndSearchURLOverride = hound.URL + ghBaseURLOverride = gh.URL + "/" + defer func() { houndSearchURLOverride = ""; ghBaseURLOverride = "" }() + + root := t.TempDir() + for _, name := range validOrgs { + dir := filepath.Join(root, "config", name) + _ = os.MkdirAll(dir, 0o755) + _ = os.WriteFile(filepath.Join(dir, "org.yaml"), []byte("members: []\n"), 0o644) + } + + usernames := []string{"alice", "ghost", "bob"} + var buf bytes.Buffer + err := runUserinfo(context.Background(), root, usernames, true, true, &buf) + if err == nil { + t.Fatal("expected a joined error for the failed user") + } + + var got []*UserDetails + if unmarshalErr := json.Unmarshal(buf.Bytes(), &got); unmarshalErr != nil { + t.Fatalf("decode: %v\nraw: %s", unmarshalErr, buf.String()) + } + if len(got) != len(usernames) { + t.Fatalf("got %d entries, want %d", len(got), len(usernames)) + } + + for i, name := range usernames { + if got[i].Username != name { + t.Errorf("entry %d: got username %q, want %q (order not preserved)", i, got[i].Username, name) + } + } + if got[0].Company != "Acme" || got[0].Error != "" { + t.Errorf("alice: got %+v", got[0]) + } + if got[1].Error == "" || got[1].Company != "" { + t.Errorf("ghost: expected error-only entry, got %+v", got[1]) + } + if got[2].Company != "Corp" || got[2].Error != "" { + t.Errorf("bob: got %+v", got[2]) + } +} diff --git a/go.mod b/go.mod index e1db9df7fa..53c6c03a57 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,16 @@ module k8s.io/org -go 1.22.4 +go 1.25.0 require ( github.com/bmatcuk/doublestar v1.3.4 github.com/go-git/go-git/v5 v5.6.1 + github.com/google/go-github/v88 v88.0.0 github.com/hound-search/hound v0.7.1 github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5 github.com/sirupsen/logrus v1.9.0 github.com/spf13/cobra v1.7.0 + golang.org/x/sync v0.8.0 k8s.io/apimachinery v0.27.4 sigs.k8s.io/prow v0.0.0-20240418142548-4c9d8ca1213d sigs.k8s.io/yaml v1.5.0 @@ -44,6 +46,7 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/gomodule/redigo v1.8.5 // indirect github.com/google/btree v1.0.1 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/google/gofuzz v1.2.1-0.20210504230335-f78f29fc09ea // indirect github.com/imdario/mergo v0.3.13 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -73,7 +76,6 @@ require ( golang.org/x/mod v0.10.0 // indirect golang.org/x/net v0.10.0 // indirect golang.org/x/oauth2 v0.8.0 // indirect - golang.org/x/sync v0.2.0 // indirect golang.org/x/sys v0.8.0 // indirect golang.org/x/text v0.9.0 // indirect golang.org/x/tools v0.8.0 // indirect diff --git a/go.sum b/go.sum index 15e2497f91..201454eca9 100644 --- a/go.sum +++ b/go.sum @@ -696,8 +696,14 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v88 v88.0.0 h1:dZA9IKkPK1eXZj4ypngnpRj5FwdpTv4whix2PrQMP7M= +github.com/google/go-github/v88 v88.0.0/go.mod h1:rufTDgn2N45wjhukLTyxmvc9nilSp3mr3Rgtt6b1MPw= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.1-0.20210504230335-f78f29fc09ea h1:VcIYpAGBae3Z6BVncE0OnTE/ZjlDXqtYhOZky88neLM= github.com/google/gofuzz v1.2.1-0.20210504230335-f78f29fc09ea/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -934,6 +940,8 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= golang.org/x/arch v0.1.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -1108,8 +1116,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= -golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1631,7 +1639,5 @@ sigs.k8s.io/prow v0.0.0-20240418142548-4c9d8ca1213d h1:HisPy9Z6hS7pbFAQ4G61Uh9lm sigs.k8s.io/prow v0.0.0-20240418142548-4c9d8ca1213d/go.mod h1:7rsZ1ej4cIWtv+w/+62mLOaGMONtsG663VD9eJ7UKL4= sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE= sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= -sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= -sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4=