wip: commit progress
This commit is contained in:
parent
25d301c654
commit
9f035798d1
23
.env.example
Normal file
23
.env.example
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# This should be the value of the GitHub Actions actor who triggered a run. The
|
||||||
|
# CI script will inject this value from the GitHub Actions context to verify
|
||||||
|
# whether changing certain README fields is allowed. In local development, you
|
||||||
|
# can set this to your GitHub username.
|
||||||
|
CI_ACTOR=
|
||||||
|
|
||||||
|
# This is the Git ref that you want to merge into the main branch. In local
|
||||||
|
# development, this should be set to the value of the branch you're working from
|
||||||
|
CI_BASE_REF=
|
||||||
|
|
||||||
|
# This is the configurable base URL for accessing the GitHub REST API. This
|
||||||
|
# value will be injected by the CI script's Actions context, but if the value is
|
||||||
|
# not defined (either in CI or when running locally), "https://api.github.com/"
|
||||||
|
# will be used as a fallback.
|
||||||
|
GITHUB_API_URL=
|
||||||
|
|
||||||
|
# This is the API token for the user that will be used to authenticate calls to
|
||||||
|
# the GitHub API. In CI, the value will be loaded with a token belonging to a
|
||||||
|
# Coder Registry admin to verify whether modifying certain README fields is
|
||||||
|
# allowed. In local development, you can set a token with the read:org
|
||||||
|
# permission. If the loaded token does not belong to a Coder employee, certain
|
||||||
|
# README verification steps will be skipped.
|
||||||
|
GITHUB_API_TOKEN=
|
||||||
@ -1,5 +0,0 @@
|
|||||||
ACTOR=
|
|
||||||
BASE_REF=
|
|
||||||
HEAD_REF=
|
|
||||||
GITHUB_API_URL=
|
|
||||||
GITHUB_API_TOKEN=
|
|
||||||
@ -10,16 +10,14 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultGithubAPIRoute = "https://api.github.com/"
|
const defaultGithubAPIBaseRoute = "https://api.github.com/"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
actionsActorKey = "ACTOR"
|
actionsActorKey = "CI_ACTOR"
|
||||||
actionsBaseRefKey = "BASE_REF"
|
actionsBaseRefKey = "CI_BASE_REF"
|
||||||
actionsHeadRefKey = "HEAD_REF"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -38,119 +36,157 @@ func ActionsActor() (string, error) {
|
|||||||
return username, nil
|
return username, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionsRefs returns the name of the head ref and the base ref for current CI
|
// BaseRef returns the name of the base ref for the Git branch that will be
|
||||||
// run, in that order. Both values must be loaded into the env as part of the
|
// merged into the main branch.
|
||||||
// GitHub Actions YAML file, or else the function fails.
|
func BaseRef() (string, error) {
|
||||||
func ActionsRefs() (string, string, error) {
|
|
||||||
baseRef := os.Getenv(actionsBaseRefKey)
|
baseRef := os.Getenv(actionsBaseRefKey)
|
||||||
headRef := os.Getenv(actionsHeadRefKey)
|
if baseRef == "" {
|
||||||
|
return "", fmt.Errorf("value for %q is not in env. If running from CI, please add value via ci.yaml file", actionsBaseRefKey)
|
||||||
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
|
return baseRef, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CoderEmployees represents all members of the Coder GitHub organization. This
|
// Client is a reusable REST client for making requests to the GitHub API.
|
||||||
// value should not be instantiated from outside the package, and should instead
|
// It should be instantiated via NewGithubClient
|
||||||
// be created via one of the package's exported functions.
|
type Client struct {
|
||||||
type CoderEmployees struct {
|
baseURL string
|
||||||
// Have map defined as private field to make sure that it can't ever be
|
token string
|
||||||
// mutated from an outside package
|
httpClient http.Client
|
||||||
_employees map[string]struct{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmployee takes a GitHub username and indicates whether the matching user is
|
// NewClient instantiates a GitHub client
|
||||||
// a member of the Coder organization
|
func NewClient() (*Client, error) {
|
||||||
func (ce *CoderEmployees) IsEmployee(username string) bool {
|
// Considered letting the user continue on with no token and more aggressive
|
||||||
if ce._employees == nil {
|
// rate-limiting, but from experimentation, the non-authenticated experience
|
||||||
return false
|
// hit the rate limits really quickly, and had a lot of restrictions
|
||||||
|
apiToken := os.Getenv(githubAPITokenKey)
|
||||||
|
if apiToken == "" {
|
||||||
|
return nil, fmt.Errorf("missing env variable %q", githubAPITokenKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, ok := ce._employees[username]
|
baseURL := os.Getenv(githubAPIURLKey)
|
||||||
return ok
|
if baseURL == "" {
|
||||||
|
log.Printf("env variable %q is not defined. Falling back to %q\n", githubAPIURLKey, defaultGithubAPIBaseRoute)
|
||||||
|
baseURL = defaultGithubAPIBaseRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
baseURL: baseURL,
|
||||||
|
token: apiToken,
|
||||||
|
httpClient: http.Client{Timeout: 10 * time.Second},
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TotalEmployees returns the number of members in the Coder organization
|
// User represents a truncated version of the API response from Github's /user
|
||||||
func (ce *CoderEmployees) TotalEmployees() int {
|
// endpoint.
|
||||||
return len(ce._employees)
|
type User struct {
|
||||||
}
|
|
||||||
|
|
||||||
type ghOrganizationMember struct {
|
|
||||||
Login string `json:"login"`
|
Login string `json:"login"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ghRateLimitedRes struct {
|
// GetUserFromToken returns the user associated with the loaded API token
|
||||||
Message string `json:"message"`
|
func (gc *Client) GetUserFromToken() (User, error) {
|
||||||
}
|
req, err := http.NewRequest("GET", gc.baseURL+"user", nil)
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return CoderEmployees{}, fmt.Errorf("coder employee names: %v", err)
|
return User{}, err
|
||||||
}
|
}
|
||||||
if token != "" {
|
if gc.token != "" {
|
||||||
req.Header.Add("Authorization", "Bearer "+token)
|
req.Header.Add("Authorization", "Bearer "+gc.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := http.Client{Timeout: 5 * time.Second}
|
res, err := gc.httpClient.Do(req)
|
||||||
res, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CoderEmployees{}, fmt.Errorf("coder employee names: %v", err)
|
return User{}, err
|
||||||
}
|
}
|
||||||
defer res.Body.Close()
|
defer res.Body.Close()
|
||||||
if res.StatusCode != http.StatusOK {
|
|
||||||
return CoderEmployees{}, fmt.Errorf("coder employee names: got back status code %d", res.StatusCode)
|
if res.StatusCode == http.StatusUnauthorized {
|
||||||
|
return User{}, errors.New("request is not authorized")
|
||||||
|
}
|
||||||
|
if res.StatusCode == http.StatusForbidden {
|
||||||
|
return User{}, errors.New("request is forbidden")
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := io.ReadAll(res.Body)
|
b, err := io.ReadAll(res.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CoderEmployees{}, fmt.Errorf("coder employee names: %v", err)
|
return User{}, err
|
||||||
}
|
|
||||||
rawMembers, err := parseResponse[[]ghOrganizationMember](b)
|
|
||||||
if err != nil {
|
|
||||||
return CoderEmployees{}, fmt.Errorf("coder employee names: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
employeesSet := map[string]struct{}{}
|
user := User{}
|
||||||
for _, m := range rawMembers {
|
if err := json.Unmarshal(b, &user); err != nil {
|
||||||
employeesSet[m.Login] = struct{}{}
|
return User{}, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgStatus indicates whether a GitHub user is a member of a given organization
|
||||||
|
type OrgStatus int
|
||||||
|
|
||||||
|
var _ fmt.Stringer = OrgStatus(0)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// OrgStatusIndeterminate indicates when a user's organization status
|
||||||
|
// could not be determined. It is the zero value of the OrgStatus type, and
|
||||||
|
// any users with this value should be treated as completely untrusted
|
||||||
|
OrgStatusIndeterminate = iota
|
||||||
|
// OrgStatusNonMember indicates when a user is definitely NOT part of an
|
||||||
|
// organization
|
||||||
|
OrgStatusNonMember
|
||||||
|
// OrgStatusMember indicates when a user is a member of a Github
|
||||||
|
// organization
|
||||||
|
OrgStatusMember
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s OrgStatus) String() string {
|
||||||
|
switch s {
|
||||||
|
case OrgStatusMember:
|
||||||
|
return "Member"
|
||||||
|
case OrgStatusNonMember:
|
||||||
|
return "Non-member"
|
||||||
|
default:
|
||||||
|
return "Indeterminate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserOrgStatus takes a GitHub username, and checks the GitHub API to see
|
||||||
|
// whether that member is part of the Coder organization
|
||||||
|
func (gc *Client) GetUserOrgStatus(org string, username string) (OrgStatus, error) {
|
||||||
|
// This API endpoint is really annoying, because it's able to produce false
|
||||||
|
// negatives. Any user can be a public member of Coder, a private member of
|
||||||
|
// Coder, or a non-member.
|
||||||
|
//
|
||||||
|
// So if the function returns status 200, you can always trust that. But if
|
||||||
|
// it returns any 400 code, that could indicate a few things:
|
||||||
|
// 1. The user being checked is not part of the organization, but the user
|
||||||
|
// associated with the token is.
|
||||||
|
// 2. The user being checked is a member of the organization, but their
|
||||||
|
// status is private, and the token being used to check belongs to a user
|
||||||
|
// who is not part of the Coder organization.
|
||||||
|
// 3. Neither the user being checked nor the user associated with the token
|
||||||
|
// are members of the organization
|
||||||
|
//
|
||||||
|
// The best option is to make sure that the token being used belongs to a
|
||||||
|
// member of the Coder organization
|
||||||
|
req, err := http.NewRequest("GET", fmt.Sprintf("%sorgs/%s/%s", gc.baseURL, org, username), nil)
|
||||||
|
if err != nil {
|
||||||
|
return OrgStatusIndeterminate, err
|
||||||
|
}
|
||||||
|
if gc.token != "" {
|
||||||
|
req.Header.Add("Authorization", "Bearer "+gc.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := gc.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return OrgStatusIndeterminate, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
switch res.StatusCode {
|
||||||
|
case http.StatusNoContent:
|
||||||
|
return OrgStatusMember, nil
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return OrgStatusNonMember, nil
|
||||||
|
default:
|
||||||
|
return OrgStatusIndeterminate, nil
|
||||||
}
|
}
|
||||||
return CoderEmployees{
|
|
||||||
_employees: employeesSet,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"coder.com/coder-registry/cmd/github"
|
"coder.com/coder-registry/cmd/github"
|
||||||
@ -14,26 +15,43 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
log.Println("Beginning README file validation")
|
||||||
err := godotenv.Load()
|
err := godotenv.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Panic(err)
|
log.Panic(err)
|
||||||
}
|
}
|
||||||
username, err := github.ActionsActor()
|
actorUsername, err := github.ActionsActor()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Panic(err)
|
log.Panic(err)
|
||||||
}
|
}
|
||||||
log.Printf("Running validation for user %q", username)
|
baseRef, err := github.BaseRef()
|
||||||
headRef, baseRef, err := github.ActionsRefs()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Panic(err)
|
log.Panic(err)
|
||||||
}
|
}
|
||||||
log.Printf("Using branches %q and %q for validation comparison", headRef, baseRef)
|
log.Printf("Using branch %q for validation comparison", baseRef)
|
||||||
|
|
||||||
employees, err := github.CoderEmployeeUsernames()
|
log.Printf("Using GitHub API to determine what fields can be set by user %q\n", actorUsername)
|
||||||
|
client, err := github.NewClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Panic(err)
|
log.Panic(err)
|
||||||
}
|
}
|
||||||
log.Printf("got back %d employees\n", employees.TotalEmployees())
|
tokenUser, err := client.GetUserFromToken()
|
||||||
|
if err != nil {
|
||||||
|
log.Panic(err)
|
||||||
|
}
|
||||||
|
tokenUserStatus, err := client.GetUserOrgStatus("coder", tokenUser.Login)
|
||||||
|
if err != nil {
|
||||||
|
log.Panic(err)
|
||||||
|
}
|
||||||
|
var actorOrgStatus github.OrgStatus
|
||||||
|
if tokenUserStatus == github.OrgStatusMember {
|
||||||
|
actorOrgStatus, err = client.GetUserOrgStatus("coder", actorUsername)
|
||||||
|
if err != nil {
|
||||||
|
log.Panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("actor %q is %s\n", actorUsername, actorOrgStatus.String())
|
||||||
|
|
||||||
log.Println("Starting README validation")
|
log.Println("Starting README validation")
|
||||||
allReadmeFiles, err := aggregateContributorReadmeFiles()
|
allReadmeFiles, err := aggregateContributorReadmeFiles()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user