diff --git a/.env_example b/.env_example new file mode 100644 index 00000000..267b2039 --- /dev/null +++ b/.env_example @@ -0,0 +1,5 @@ +ACTOR= +BASE_REF= +HEAD_REF= +GITHUB_API_URL= +GITHUB_API_TOKEN= \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0d3f9eab..889c46cf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,9 +10,11 @@ jobs: validate-contributors: runs-on: ubuntu-latest env: - actor: ${{ github.actor }} - base_ref: ${{ github.base_ref }} - head_ref: ${{ github.head_ref }} + ACTOR: ${{ github.actor }} + BASE_REF: ${{ github.base_ref }} + HEAD_REF: ${{ github.head_ref }} + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Check out code uses: actions/checkout@v4 diff --git a/cmd/github/github.go b/cmd/github/github.go index 7ebb01df..d5f67842 100644 --- a/cmd/github/github.go +++ b/cmd/github/github.go @@ -3,15 +3,28 @@ package github import ( + "encoding/json" "errors" "fmt" + "io" + "log" + "net/http" "os" + "strings" + "time" +) + +const defaultGithubAPIRoute = "https://api.github.com/" + +const ( + actionsActorKey = "ACTOR" + actionsBaseRefKey = "BASE_REF" + actionsHeadRefKey = "HEAD_REF" ) const ( - actionsActorKey = "actor" - actionsBaseRefKey = "base_ref" - actionsHeadRefKey = "head_ref" + githubAPIURLKey = "GITHUB_API_URL" + githubAPITokenKey = "GITHUB_API_TOKEN" ) // ActionsActor returns the username of the GitHub user who triggered the @@ -20,7 +33,7 @@ const ( func ActionsActor() (string, error) { username := os.Getenv(actionsActorKey) if username == "" { - return "", fmt.Errorf("value for %q is not in env. Please update the CI script to load the value in during CI", actionsActorKey) + return "", fmt.Errorf("value for %q is not in env. If running from CI, please add value via ci.yaml file", actionsActorKey) } return username, nil } @@ -31,8 +44,113 @@ func ActionsActor() (string, error) { func ActionsRefs() (string, string, error) { baseRef := os.Getenv(actionsBaseRefKey) headRef := os.Getenv(actionsHeadRefKey) - fmt.Println("Base ref: ", baseRef) - fmt.Println("Head ref: ", headRef) - return "", "", errors.New("we ain't ready yet") + if baseRef == "" && headRef == "" { + return "", "", fmt.Errorf("values for %q and %q are not in env. If running from CI, please add values via ci.yaml file", actionsHeadRefKey, actionsBaseRefKey) + } else if headRef == "" { + return "", "", fmt.Errorf("value for %q is not in env. If running from CI, please add value via ci.yaml file", actionsHeadRefKey) + } else if baseRef == "" { + return "", "", fmt.Errorf("value for %q is not in env. If running from CI, please add value via ci.yaml file", actionsBaseRefKey) + } + + return headRef, baseRef, nil +} + +// CoderEmployees represents all members of the Coder GitHub organization. This +// value should not be instantiated from outside the package, and should instead +// be created via one of the package's exported functions. +type CoderEmployees struct { + // Have map defined as private field to make sure that it can't ever be + // mutated from an outside package + _employees map[string]struct{} +} + +// IsEmployee takes a GitHub username and indicates whether the matching user is +// a member of the Coder organization +func (ce *CoderEmployees) IsEmployee(username string) bool { + if ce._employees == nil { + return false + } + + _, ok := ce._employees[username] + return ok +} + +// TotalEmployees returns the number of members in the Coder organization +func (ce *CoderEmployees) TotalEmployees() int { + return len(ce._employees) +} + +type ghOrganizationMember struct { + Login string `json:"login"` +} + +type ghRateLimitedRes struct { + Message string `json:"message"` +} + +func parseResponse[V any](b []byte) (V, error) { + var want V + var rateLimitedRes ghRateLimitedRes + + if err := json.Unmarshal(b, &rateLimitedRes); err != nil { + return want, err + } + if isRateLimited := strings.Contains(rateLimitedRes.Message, "API rate limit exceeded for "); isRateLimited { + return want, errors.New("request was rate-limited") + } + if err := json.Unmarshal(b, &want); err != nil { + return want, err + } + + return want, nil +} + +// CoderEmployeeUsernames requests from the GitHub API the list of all usernames +// of people who are employees of Coder. +func CoderEmployeeUsernames() (CoderEmployees, error) { + apiURL := os.Getenv(githubAPIURLKey) + if apiURL == "" { + log.Printf("API URL not set via env key %q. Defaulting to %q\n", githubAPIURLKey, defaultGithubAPIRoute) + apiURL = defaultGithubAPIRoute + } + token := os.Getenv(githubAPITokenKey) + if token == "" { + log.Printf("API token not set via env key %q. All requests will be non-authenticated and subject to more aggressive rate limiting", githubAPITokenKey) + } + + req, err := http.NewRequest("GET", apiURL+"/orgs/coder/members", nil) + if err != nil { + return CoderEmployees{}, fmt.Errorf("coder employee names: %v", err) + } + if token != "" { + req.Header.Add("Authorization", "Bearer "+token) + } + + client := http.Client{Timeout: 5 * time.Second} + res, err := client.Do(req) + if err != nil { + return CoderEmployees{}, fmt.Errorf("coder employee names: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return CoderEmployees{}, fmt.Errorf("coder employee names: got back status code %d", res.StatusCode) + } + + b, err := io.ReadAll(res.Body) + if err != nil { + return CoderEmployees{}, fmt.Errorf("coder employee names: %v", err) + } + rawMembers, err := parseResponse[[]ghOrganizationMember](b) + if err != nil { + return CoderEmployees{}, fmt.Errorf("coder employee names: %v", err) + } + + employeesSet := map[string]struct{}{} + for _, m := range rawMembers { + employeesSet[m.Login] = struct{}{} + } + return CoderEmployees{ + _employees: employeesSet, + }, nil } diff --git a/cmd/readmevalidation/main.go b/cmd/readmevalidation/main.go index 07cbf95d..8fd657c2 100644 --- a/cmd/readmevalidation/main.go +++ b/cmd/readmevalidation/main.go @@ -10,18 +10,30 @@ import ( "log" "coder.com/coder-registry/cmd/github" + "github.com/joho/godotenv" ) func main() { + err := godotenv.Load() + if err != nil { + log.Panic(err) + } username, err := github.ActionsActor() if err != nil { log.Panic(err) } - log.Printf("running as %q\n", username) - _, _, err = github.ActionsRefs() + log.Printf("Running validation for user %q", username) + headRef, baseRef, err := github.ActionsRefs() if err != nil { log.Panic(err) } + log.Printf("Using branches %q and %q for validation comparison", headRef, baseRef) + + employees, err := github.CoderEmployeeUsernames() + if err != nil { + log.Panic(err) + } + log.Printf("got back %d employees\n", employees.TotalEmployees()) log.Println("Starting README validation") allReadmeFiles, err := aggregateContributorReadmeFiles() diff --git a/go.mod b/go.mod index e4074228..61231b92 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,5 @@ module coder.com/coder-registry go 1.23.2 require gopkg.in/yaml.v3 v3.0.1 + +require github.com/joho/godotenv v1.5.1 // indirect diff --git a/go.sum b/go.sum index a62c313c..e536c30f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=