Files
gh-repo-bootstrap/internal/githubapi/resolve.go
T
2026-05-26 09:09:47 -05:00

104 lines
2.7 KiB
Go

// Package githubapi resolves human-friendly GitHub identifiers (logins,
// org/team slugs) into numeric IDs by shelling out to `gh api`. Numeric
// inputs short-circuit.
package githubapi
import (
"encoding/json"
"fmt"
"os/exec"
"strconv"
"strings"
"sync"
)
// Resolver caches user/team lookups for the lifetime of a single run.
type Resolver struct {
mu sync.Mutex
users map[string]int
teams map[string]int
}
// New returns a fresh Resolver.
func New() *Resolver {
return &Resolver{users: map[string]int{}, teams: map[string]int{}}
}
// ResolveUser turns a numeric string or a GitHub login (with optional leading
// `@`) into a numeric user ID. Numeric inputs are passed through unchanged.
func (r *Resolver) ResolveUser(s string) (int, error) {
s = strings.TrimPrefix(strings.TrimSpace(s), "@")
if id, err := strconv.Atoi(s); err == nil {
return id, nil
}
r.mu.Lock()
if id, ok := r.users[s]; ok {
r.mu.Unlock()
return id, nil
}
r.mu.Unlock()
id, err := ghAPIID("users/" + s)
if err != nil {
return 0, fmt.Errorf("resolving user %q: %w", s, err)
}
r.mu.Lock()
r.users[s] = id
r.mu.Unlock()
return id, nil
}
// ResolveTeam turns a numeric string or an `org/team-slug` pair into a
// numeric team ID. Bare slugs without an org prefix are rejected because the
// org cannot be inferred unambiguously.
func (r *Resolver) ResolveTeam(s string) (int, error) {
s = strings.TrimSpace(s)
if id, err := strconv.Atoi(s); err == nil {
return id, nil
}
if !strings.Contains(s, "/") {
return 0, fmt.Errorf("team reviewer %q must be in the form org/team-slug", s)
}
r.mu.Lock()
if id, ok := r.teams[s]; ok {
r.mu.Unlock()
return id, nil
}
r.mu.Unlock()
parts := strings.SplitN(s, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return 0, fmt.Errorf("team reviewer %q must be in the form org/team-slug", s)
}
id, err := ghAPIID(fmt.Sprintf("orgs/%s/teams/%s", parts[0], parts[1]))
if err != nil {
return 0, fmt.Errorf("resolving team %q: %w", s, err)
}
r.mu.Lock()
r.teams[s] = id
r.mu.Unlock()
return id, nil
}
var ExecCommand = exec.Command
// ghAPIID runs `gh api <path>` and returns the `.id` field of the response.
func ghAPIID(path string) (int, error) {
cmd := ExecCommand("gh", "api", path)
out, err := cmd.Output()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
return 0, fmt.Errorf("gh api %s: %s", path, strings.TrimSpace(string(ee.Stderr)))
}
return 0, fmt.Errorf("gh api %s: %w", path, err)
}
var body struct {
ID int `json:"id"`
}
if err := json.Unmarshal(out, &body); err != nil {
return 0, fmt.Errorf("decoding gh api %s response: %w", path, err)
}
if body.ID == 0 {
return 0, fmt.Errorf("gh api %s returned no id", path)
}
return body.ID, nil
}