Skip to content

Commit d785d6c

Browse files
committed
korg: add userinfo command for org and OWNERS lookup
Adds `korg userinfo <user>...` to answer the recurring question this repo exists for: which Kubernetes orgs is a user in, what role, and where do they appear in OWNERS files. Sources: - GitHub user API for company / profile - config/<org>/org.yaml (Members / Admins) for k8s org membership - cs.k8s.io (hound) for OWNERS file references Behavior: - Honors GITHUB_TOKEN / GH_TOKEN to avoid the 60/hr unauth limit - 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 and the process exits non-zero - Hound failures degrade to a warning instead of dropping org info - OWNERS results render as an aligned REPO / PATH / URL table; URL uses /blob/HEAD/ so the link resolves to the default branch - `--output json` for scripting Dependencies: - google/go-github v17 (untagged) -> v88 - spf13/cobra v0.0.5 -> v1.10.2 (for ExecuteContext) - adds golang.org/x/sync for errgroup - go directive bumped to 1.22+ Signed-off-by: Nabarun Pal <pal.nabarun95@gmail.com>
1 parent 8a7295b commit d785d6c

5 files changed

Lines changed: 1286 additions & 203 deletions

File tree

cmd/korg/korg.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@ limitations under the License.
1717
package main
1818

1919
import (
20+
"context"
2021
"fmt"
2122
"os"
23+
"os/signal"
2224
"path/filepath"
2325
"strings"
26+
"syscall"
2427

2528
"github.com/spf13/cobra"
2629
)
@@ -58,6 +61,16 @@ Note: Removing from teams is currently unsupported.
5861
`
5962

6063
auditHelpText = "Audit GitHub org members"
64+
65+
userinfoHelpText = `
66+
Gets k8s org membership, GitHub profile, and OWNERS file references for user(s).
67+
68+
Honors GITHUB_TOKEN / GH_TOKEN for authenticated GitHub API access.
69+
70+
korg userinfo <github username>
71+
korg userinfo <github username1> <github username2> <github username3> ...
72+
korg userinfo --output json <github username>
73+
`
6174
)
6275

6376
type Options struct {
@@ -222,7 +235,29 @@ func main() {
222235
rootCmd.AddCommand(removeCmd)
223236
rootCmd.AddCommand(auditCmd)
224237

225-
if err := rootCmd.Execute(); err != nil {
238+
var outputFormat string
239+
userInfoCmd := &cobra.Command{
240+
Use: "userinfo",
241+
Short: "Get information about user(s)",
242+
Long: userinfoHelpText,
243+
Args: cobra.MinimumNArgs(1),
244+
RunE: func(cmd *cobra.Command, args []string) error {
245+
switch outputFormat {
246+
case "text", "json":
247+
default:
248+
return fmt.Errorf("invalid --output %q (want: text, json)", outputFormat)
249+
}
250+
return runUserinfo(cmd.Context(), o.RepoRoot, args, outputFormat == "json", cmd.OutOrStdout())
251+
},
252+
}
253+
userInfoCmd.Flags().StringVarP(&outputFormat, "output", "o", "text", "output format: text|json")
254+
rootCmd.AddCommand(userInfoCmd)
255+
256+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
257+
defer stop()
258+
259+
if err := rootCmd.ExecuteContext(ctx); err != nil {
260+
fmt.Fprintln(os.Stderr, "error:", err)
226261
os.Exit(1)
227262
}
228263
}

cmd/korg/userinfo.go

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
/*
2+
Copyright 2023 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package main
18+
19+
import (
20+
"bytes"
21+
"context"
22+
"encoding/json"
23+
"errors"
24+
"fmt"
25+
"io"
26+
"net/http"
27+
"net/url"
28+
"os"
29+
"path/filepath"
30+
"sort"
31+
"text/tabwriter"
32+
"time"
33+
34+
"github.com/google/go-github/v88/github"
35+
houndclient "github.com/hound-search/hound/client"
36+
"golang.org/x/sync/errgroup"
37+
"k8s.io/test-infra/prow/config/org"
38+
)
39+
40+
const (
41+
houndSearchURL = "https://cs.k8s.io/api/v1/search"
42+
defaultHTTPTimeout = 30 * time.Second
43+
userinfoConcurrency = 4
44+
)
45+
46+
// Test-only overrides. Empty means use defaults / api.github.com.
47+
var (
48+
houndSearchURLOverride string
49+
ghBaseURLOverride string
50+
)
51+
52+
func houndURL() string {
53+
if houndSearchURLOverride != "" {
54+
return houndSearchURLOverride
55+
}
56+
return houndSearchURL
57+
}
58+
59+
type OrgMembership struct {
60+
Org string `json:"org"`
61+
Role string `json:"role"` // "member" or "admin"
62+
}
63+
64+
type OwnerFile struct {
65+
Repo string `json:"repo"`
66+
Path string `json:"path"`
67+
URL string `json:"url"`
68+
}
69+
70+
type UserInfo struct {
71+
Username string `json:"username"`
72+
Company string `json:"company,omitempty"`
73+
Orgs []OrgMembership `json:"orgs"`
74+
OwnerFiles []OwnerFile `json:"owner_files,omitempty"`
75+
Warnings []string `json:"warnings,omitempty"`
76+
}
77+
78+
// findUserInfo gathers GitHub profile, k8s org membership, and OWNERS file references for a username.
79+
// GitHub or k/org config failures are fatal (returned as error). Hound failures are non-fatal warnings.
80+
func findUserInfo(ctx context.Context, gh *github.Client, hc *http.Client, configs map[string]*org.Config, username string) (*UserInfo, error) {
81+
info := &UserInfo{Username: username}
82+
83+
u, _, err := gh.Users.Get(ctx, username)
84+
if err != nil {
85+
return nil, fmt.Errorf("github user %q: %w", username, err)
86+
}
87+
if u.Company != nil {
88+
info.Company = *u.Company
89+
}
90+
91+
info.Orgs = findOrgMembership(configs, username)
92+
93+
files, err := searchOwnerFiles(ctx, hc, username)
94+
if err != nil {
95+
info.Warnings = append(info.Warnings, fmt.Sprintf("OWNERS lookup failed: %v", err))
96+
} else {
97+
info.OwnerFiles = files
98+
}
99+
100+
return info, nil
101+
}
102+
103+
func findOrgMembership(configs map[string]*org.Config, username string) []OrgMembership {
104+
out := []OrgMembership{}
105+
orgNames := make([]string, 0, len(configs))
106+
for name := range configs {
107+
orgNames = append(orgNames, name)
108+
}
109+
sort.Strings(orgNames)
110+
111+
for _, name := range orgNames {
112+
cfg := configs[name]
113+
switch {
114+
case stringInSliceCaseAgnostic(cfg.Admins, username):
115+
out = append(out, OrgMembership{Org: name, Role: "admin"})
116+
case stringInSliceCaseAgnostic(cfg.Members, username):
117+
out = append(out, OrgMembership{Org: name, Role: "member"})
118+
}
119+
}
120+
return out
121+
}
122+
123+
func loadOrgConfigs(repoRoot string, orgs []string) (map[string]*org.Config, error) {
124+
out := make(map[string]*org.Config, len(orgs))
125+
for _, name := range orgs {
126+
path := filepath.Join(repoRoot, fmt.Sprintf(orgConfigPathFormat, name))
127+
cfg, err := readConfig(path)
128+
if err != nil {
129+
return nil, fmt.Errorf("loading org config %s: %w", name, err)
130+
}
131+
out[name] = cfg
132+
}
133+
return out, nil
134+
}
135+
136+
func ownerFileURL(repo, path string) string {
137+
return fmt.Sprintf("https://github.com/%s/blob/HEAD/%s", repo, path)
138+
}
139+
140+
func searchOwnerFiles(ctx context.Context, hc *http.Client, username string) ([]OwnerFile, error) {
141+
q := url.Values{}
142+
q.Set("q", username)
143+
q.Set("repos", "*")
144+
q.Set("rng", ":20")
145+
q.Set("files", "OWNERS")
146+
q.Set("excludeFiles", "vendor/")
147+
q.Set("i", "true")
148+
q.Set("stats", "true")
149+
150+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, houndURL()+"?"+q.Encode(), nil)
151+
if err != nil {
152+
return nil, err
153+
}
154+
155+
resp, err := hc.Do(req)
156+
if err != nil {
157+
return nil, err
158+
}
159+
defer resp.Body.Close()
160+
161+
if resp.StatusCode/100 != 2 {
162+
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
163+
return nil, fmt.Errorf("hound %s: %s", resp.Status, bytes.TrimSpace(snippet))
164+
}
165+
166+
body, err := io.ReadAll(resp.Body)
167+
if err != nil {
168+
return nil, err
169+
}
170+
171+
var r houndclient.Response
172+
if err := json.Unmarshal(body, &r); err != nil {
173+
return nil, fmt.Errorf("decoding hound response: %w", err)
174+
}
175+
176+
out := []OwnerFile{}
177+
for repo, matches := range r.Results {
178+
if matches == nil {
179+
continue
180+
}
181+
for _, fm := range matches.Matches {
182+
out = append(out, OwnerFile{
183+
Repo: repo,
184+
Path: fm.Filename,
185+
URL: ownerFileURL(repo, fm.Filename),
186+
})
187+
}
188+
}
189+
sort.Slice(out, func(i, j int) bool {
190+
if out[i].Repo != out[j].Repo {
191+
return out[i].Repo < out[j].Repo
192+
}
193+
return out[i].Path < out[j].Path
194+
})
195+
return out, nil
196+
}
197+
198+
// renderText writes a human-readable form of UserInfo to w.
199+
func (u *UserInfo) renderText(w io.Writer) {
200+
fmt.Fprintf(w, "\n=== %s\n", u.Username)
201+
if u.Company != "" {
202+
fmt.Fprintln(w, "Company:", u.Company)
203+
} else {
204+
fmt.Fprintln(w, "Company: **Not Found**")
205+
}
206+
207+
fmt.Fprintln(w, "Orgs:")
208+
if len(u.Orgs) == 0 {
209+
fmt.Fprintln(w, " (none)")
210+
}
211+
for _, m := range u.Orgs {
212+
fmt.Fprintf(w, " %s (%s)\n", m.Org, m.Role)
213+
}
214+
215+
if len(u.OwnerFiles) > 0 {
216+
fmt.Fprintln(w, "Owner Files:")
217+
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
218+
fmt.Fprintln(tw, " REPO\tPATH\tURL")
219+
for _, of := range u.OwnerFiles {
220+
fmt.Fprintf(tw, " %s\t%s\t%s\n", of.Repo, of.Path, of.URL)
221+
}
222+
tw.Flush()
223+
}
224+
225+
for _, warn := range u.Warnings {
226+
fmt.Fprintln(w, "Warning:", warn)
227+
}
228+
}
229+
230+
// newGitHubClient returns a GitHub client authenticated via GITHUB_TOKEN or GH_TOKEN if set.
231+
func newGitHubClient(ctx context.Context, httpClient *http.Client) (*github.Client, error) {
232+
token := os.Getenv("GITHUB_TOKEN")
233+
if token == "" {
234+
token = os.Getenv("GH_TOKEN")
235+
}
236+
opts := []github.ClientOptionsFunc{}
237+
if httpClient != nil {
238+
opts = append(opts, github.WithHTTPClient(httpClient))
239+
}
240+
if token != "" {
241+
opts = append(opts, github.WithAuthToken(token))
242+
}
243+
if ghBaseURLOverride != "" {
244+
opts = append(opts, github.WithEnterpriseURLs(ghBaseURLOverride, ghBaseURLOverride))
245+
}
246+
return github.NewClient(opts...)
247+
}
248+
249+
// runUserinfo fetches info for every username concurrently and writes ordered output to w.
250+
// Returns a joined error of all per-user failures; users without errors still render.
251+
func runUserinfo(ctx context.Context, repoRoot string, usernames []string, outputJSON bool, w io.Writer) error {
252+
configs, err := loadOrgConfigs(repoRoot, validOrgs)
253+
if err != nil {
254+
return err
255+
}
256+
257+
hc := &http.Client{Timeout: defaultHTTPTimeout}
258+
gh, err := newGitHubClient(ctx, hc)
259+
if err != nil {
260+
return fmt.Errorf("building github client: %w", err)
261+
}
262+
263+
results := make([]*UserInfo, len(usernames))
264+
errs := make([]error, len(usernames))
265+
266+
g, gctx := errgroup.WithContext(ctx)
267+
g.SetLimit(userinfoConcurrency)
268+
for i, name := range usernames {
269+
i, name := i, name
270+
g.Go(func() error {
271+
info, err := findUserInfo(gctx, gh, hc, configs, name)
272+
if err != nil {
273+
errs[i] = err
274+
return nil
275+
}
276+
results[i] = info
277+
return nil
278+
})
279+
}
280+
_ = g.Wait()
281+
282+
if outputJSON {
283+
clean := make([]*UserInfo, 0, len(results))
284+
for _, r := range results {
285+
if r != nil {
286+
clean = append(clean, r)
287+
}
288+
}
289+
enc := json.NewEncoder(w)
290+
enc.SetIndent("", " ")
291+
if err := enc.Encode(clean); err != nil {
292+
return err
293+
}
294+
} else {
295+
for i, r := range results {
296+
if r != nil {
297+
r.renderText(w)
298+
}
299+
if errs[i] != nil {
300+
fmt.Fprintf(w, "\n=== %s\nERROR: %v\n", usernames[i], errs[i])
301+
}
302+
}
303+
}
304+
305+
return errors.Join(errs...)
306+
}

0 commit comments

Comments
 (0)