Skip to content

Commit 9f03579

Browse files
committed
wip: commit progress
1 parent 25d301c commit 9f03579

File tree

4 files changed

+169
-97
lines changed

4 files changed

+169
-97
lines changed

.env.example

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# This should be the value of the GitHub Actions actor who triggered a run. The
2+
# CI script will inject this value from the GitHub Actions context to verify
3+
# whether changing certain README fields is allowed. In local development, you
4+
# can set this to your GitHub username.
5+
CI_ACTOR=
6+
7+
# This is the Git ref that you want to merge into the main branch. In local
8+
# development, this should be set to the value of the branch you're working from
9+
CI_BASE_REF=
10+
11+
# This is the configurable base URL for accessing the GitHub REST API. This
12+
# value will be injected by the CI script's Actions context, but if the value is
13+
# not defined (either in CI or when running locally), "https://api.github.com/"
14+
# will be used as a fallback.
15+
GITHUB_API_URL=
16+
17+
# This is the API token for the user that will be used to authenticate calls to
18+
# the GitHub API. In CI, the value will be loaded with a token belonging to a
19+
# Coder Registry admin to verify whether modifying certain README fields is
20+
# allowed. In local development, you can set a token with the read:org
21+
# permission. If the loaded token does not belong to a Coder employee, certain
22+
# README verification steps will be skipped.
23+
GITHUB_API_TOKEN=

.env_example

Lines changed: 0 additions & 5 deletions
This file was deleted.

cmd/github/github.go

Lines changed: 122 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,14 @@ import (
1010
"log"
1111
"net/http"
1212
"os"
13-
"strings"
1413
"time"
1514
)
1615

17-
const defaultGithubAPIRoute = "https://api.github.com/"
16+
const defaultGithubAPIBaseRoute = "https://api.github.com/"
1817

1918
const (
20-
actionsActorKey = "ACTOR"
21-
actionsBaseRefKey = "BASE_REF"
22-
actionsHeadRefKey = "HEAD_REF"
19+
actionsActorKey = "CI_ACTOR"
20+
actionsBaseRefKey = "CI_BASE_REF"
2321
)
2422

2523
const (
@@ -38,119 +36,157 @@ func ActionsActor() (string, error) {
3836
return username, nil
3937
}
4038

41-
// ActionsRefs returns the name of the head ref and the base ref for current CI
42-
// run, in that order. Both values must be loaded into the env as part of the
43-
// GitHub Actions YAML file, or else the function fails.
44-
func ActionsRefs() (string, string, error) {
39+
// BaseRef returns the name of the base ref for the Git branch that will be
40+
// merged into the main branch.
41+
func BaseRef() (string, error) {
4542
baseRef := os.Getenv(actionsBaseRefKey)
46-
headRef := os.Getenv(actionsHeadRefKey)
47-
48-
if baseRef == "" && headRef == "" {
49-
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)
50-
} else if headRef == "" {
51-
return "", "", fmt.Errorf("value for %q is not in env. If running from CI, please add value via ci.yaml file", actionsHeadRefKey)
52-
} else if baseRef == "" {
53-
return "", "", fmt.Errorf("value for %q is not in env. If running from CI, please add value via ci.yaml file", actionsBaseRefKey)
43+
if baseRef == "" {
44+
return "", fmt.Errorf("value for %q is not in env. If running from CI, please add value via ci.yaml file", actionsBaseRefKey)
5445
}
5546

56-
return headRef, baseRef, nil
47+
return baseRef, nil
5748
}
5849

59-
// CoderEmployees represents all members of the Coder GitHub organization. This
60-
// value should not be instantiated from outside the package, and should instead
61-
// be created via one of the package's exported functions.
62-
type CoderEmployees struct {
63-
// Have map defined as private field to make sure that it can't ever be
64-
// mutated from an outside package
65-
_employees map[string]struct{}
50+
// Client is a reusable REST client for making requests to the GitHub API.
51+
// It should be instantiated via NewGithubClient
52+
type Client struct {
53+
baseURL string
54+
token string
55+
httpClient http.Client
6656
}
6757

68-
// IsEmployee takes a GitHub username and indicates whether the matching user is
69-
// a member of the Coder organization
70-
func (ce *CoderEmployees) IsEmployee(username string) bool {
71-
if ce._employees == nil {
72-
return false
58+
// NewClient instantiates a GitHub client
59+
func NewClient() (*Client, error) {
60+
// Considered letting the user continue on with no token and more aggressive
61+
// rate-limiting, but from experimentation, the non-authenticated experience
62+
// hit the rate limits really quickly, and had a lot of restrictions
63+
apiToken := os.Getenv(githubAPITokenKey)
64+
if apiToken == "" {
65+
return nil, fmt.Errorf("missing env variable %q", githubAPITokenKey)
7366
}
7467

75-
_, ok := ce._employees[username]
76-
return ok
77-
}
68+
baseURL := os.Getenv(githubAPIURLKey)
69+
if baseURL == "" {
70+
log.Printf("env variable %q is not defined. Falling back to %q\n", githubAPIURLKey, defaultGithubAPIBaseRoute)
71+
baseURL = defaultGithubAPIBaseRoute
72+
}
7873

79-
// TotalEmployees returns the number of members in the Coder organization
80-
func (ce *CoderEmployees) TotalEmployees() int {
81-
return len(ce._employees)
74+
return &Client{
75+
baseURL: baseURL,
76+
token: apiToken,
77+
httpClient: http.Client{Timeout: 10 * time.Second},
78+
}, nil
8279
}
8380

84-
type ghOrganizationMember struct {
81+
// User represents a truncated version of the API response from Github's /user
82+
// endpoint.
83+
type User struct {
8584
Login string `json:"login"`
8685
}
8786

88-
type ghRateLimitedRes struct {
89-
Message string `json:"message"`
90-
}
91-
92-
func parseResponse[V any](b []byte) (V, error) {
93-
var want V
94-
var rateLimitedRes ghRateLimitedRes
95-
96-
if err := json.Unmarshal(b, &rateLimitedRes); err != nil {
97-
return want, err
98-
}
99-
if isRateLimited := strings.Contains(rateLimitedRes.Message, "API rate limit exceeded for "); isRateLimited {
100-
return want, errors.New("request was rate-limited")
87+
// GetUserFromToken returns the user associated with the loaded API token
88+
func (gc *Client) GetUserFromToken() (User, error) {
89+
req, err := http.NewRequest("GET", gc.baseURL+"user", nil)
90+
if err != nil {
91+
return User{}, err
10192
}
102-
if err := json.Unmarshal(b, &want); err != nil {
103-
return want, err
93+
if gc.token != "" {
94+
req.Header.Add("Authorization", "Bearer "+gc.token)
10495
}
10596

106-
return want, nil
107-
}
97+
res, err := gc.httpClient.Do(req)
98+
if err != nil {
99+
return User{}, err
100+
}
101+
defer res.Body.Close()
108102

109-
// CoderEmployeeUsernames requests from the GitHub API the list of all usernames
110-
// of people who are employees of Coder.
111-
func CoderEmployeeUsernames() (CoderEmployees, error) {
112-
apiURL := os.Getenv(githubAPIURLKey)
113-
if apiURL == "" {
114-
log.Printf("API URL not set via env key %q. Defaulting to %q\n", githubAPIURLKey, defaultGithubAPIRoute)
115-
apiURL = defaultGithubAPIRoute
103+
if res.StatusCode == http.StatusUnauthorized {
104+
return User{}, errors.New("request is not authorized")
116105
}
117-
token := os.Getenv(githubAPITokenKey)
118-
if token == "" {
119-
log.Printf("API token not set via env key %q. All requests will be non-authenticated and subject to more aggressive rate limiting", githubAPITokenKey)
106+
if res.StatusCode == http.StatusForbidden {
107+
return User{}, errors.New("request is forbidden")
120108
}
121109

122-
req, err := http.NewRequest("GET", apiURL+"/orgs/coder/members", nil)
110+
b, err := io.ReadAll(res.Body)
123111
if err != nil {
124-
return CoderEmployees{}, fmt.Errorf("coder employee names: %v", err)
125-
}
126-
if token != "" {
127-
req.Header.Add("Authorization", "Bearer "+token)
112+
return User{}, err
128113
}
129114

130-
client := http.Client{Timeout: 5 * time.Second}
131-
res, err := client.Do(req)
132-
if err != nil {
133-
return CoderEmployees{}, fmt.Errorf("coder employee names: %v", err)
115+
user := User{}
116+
if err := json.Unmarshal(b, &user); err != nil {
117+
return User{}, err
134118
}
135-
defer res.Body.Close()
136-
if res.StatusCode != http.StatusOK {
137-
return CoderEmployees{}, fmt.Errorf("coder employee names: got back status code %d", res.StatusCode)
119+
return user, nil
120+
}
121+
122+
// OrgStatus indicates whether a GitHub user is a member of a given organization
123+
type OrgStatus int
124+
125+
var _ fmt.Stringer = OrgStatus(0)
126+
127+
const (
128+
// OrgStatusIndeterminate indicates when a user's organization status
129+
// could not be determined. It is the zero value of the OrgStatus type, and
130+
// any users with this value should be treated as completely untrusted
131+
OrgStatusIndeterminate = iota
132+
// OrgStatusNonMember indicates when a user is definitely NOT part of an
133+
// organization
134+
OrgStatusNonMember
135+
// OrgStatusMember indicates when a user is a member of a Github
136+
// organization
137+
OrgStatusMember
138+
)
139+
140+
func (s OrgStatus) String() string {
141+
switch s {
142+
case OrgStatusMember:
143+
return "Member"
144+
case OrgStatusNonMember:
145+
return "Non-member"
146+
default:
147+
return "Indeterminate"
138148
}
149+
}
139150

140-
b, err := io.ReadAll(res.Body)
151+
// GetUserOrgStatus takes a GitHub username, and checks the GitHub API to see
152+
// whether that member is part of the Coder organization
153+
func (gc *Client) GetUserOrgStatus(org string, username string) (OrgStatus, error) {
154+
// This API endpoint is really annoying, because it's able to produce false
155+
// negatives. Any user can be a public member of Coder, a private member of
156+
// Coder, or a non-member.
157+
//
158+
// So if the function returns status 200, you can always trust that. But if
159+
// it returns any 400 code, that could indicate a few things:
160+
// 1. The user being checked is not part of the organization, but the user
161+
// associated with the token is.
162+
// 2. The user being checked is a member of the organization, but their
163+
// status is private, and the token being used to check belongs to a user
164+
// who is not part of the Coder organization.
165+
// 3. Neither the user being checked nor the user associated with the token
166+
// are members of the organization
167+
//
168+
// The best option is to make sure that the token being used belongs to a
169+
// member of the Coder organization
170+
req, err := http.NewRequest("GET", fmt.Sprintf("%sorgs/%s/%s", gc.baseURL, org, username), nil)
141171
if err != nil {
142-
return CoderEmployees{}, fmt.Errorf("coder employee names: %v", err)
172+
return OrgStatusIndeterminate, err
173+
}
174+
if gc.token != "" {
175+
req.Header.Add("Authorization", "Bearer "+gc.token)
143176
}
144-
rawMembers, err := parseResponse[[]ghOrganizationMember](b)
177+
178+
res, err := gc.httpClient.Do(req)
145179
if err != nil {
146-
return CoderEmployees{}, fmt.Errorf("coder employee names: %v", err)
180+
return OrgStatusIndeterminate, err
147181
}
182+
defer res.Body.Close()
148183

149-
employeesSet := map[string]struct{}{}
150-
for _, m := range rawMembers {
151-
employeesSet[m.Login] = struct{}{}
184+
switch res.StatusCode {
185+
case http.StatusNoContent:
186+
return OrgStatusMember, nil
187+
case http.StatusNotFound:
188+
return OrgStatusNonMember, nil
189+
default:
190+
return OrgStatusIndeterminate, nil
152191
}
153-
return CoderEmployees{
154-
_employees: employeesSet,
155-
}, nil
156192
}

cmd/readmevalidation/main.go

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,51 @@
77
package main
88

99
import (
10+
"fmt"
1011
"log"
1112

1213
"coder.com/coder-registry/cmd/github"
1314
"github.com/joho/godotenv"
1415
)
1516

1617
func main() {
18+
log.Println("Beginning README file validation")
1719
err := godotenv.Load()
1820
if err != nil {
1921
log.Panic(err)
2022
}
21-
username, err := github.ActionsActor()
23+
actorUsername, err := github.ActionsActor()
2224
if err != nil {
2325
log.Panic(err)
2426
}
25-
log.Printf("Running validation for user %q", username)
26-
headRef, baseRef, err := github.ActionsRefs()
27+
baseRef, err := github.BaseRef()
2728
if err != nil {
2829
log.Panic(err)
2930
}
30-
log.Printf("Using branches %q and %q for validation comparison", headRef, baseRef)
31+
log.Printf("Using branch %q for validation comparison", baseRef)
3132

32-
employees, err := github.CoderEmployeeUsernames()
33+
log.Printf("Using GitHub API to determine what fields can be set by user %q\n", actorUsername)
34+
client, err := github.NewClient()
3335
if err != nil {
3436
log.Panic(err)
3537
}
36-
log.Printf("got back %d employees\n", employees.TotalEmployees())
38+
tokenUser, err := client.GetUserFromToken()
39+
if err != nil {
40+
log.Panic(err)
41+
}
42+
tokenUserStatus, err := client.GetUserOrgStatus("coder", tokenUser.Login)
43+
if err != nil {
44+
log.Panic(err)
45+
}
46+
var actorOrgStatus github.OrgStatus
47+
if tokenUserStatus == github.OrgStatusMember {
48+
actorOrgStatus, err = client.GetUserOrgStatus("coder", actorUsername)
49+
if err != nil {
50+
log.Panic(err)
51+
}
52+
}
53+
54+
fmt.Printf("actor %q is %s\n", actorUsername, actorOrgStatus.String())
3755

3856
log.Println("Starting README validation")
3957
allReadmeFiles, err := aggregateContributorReadmeFiles()

0 commit comments

Comments
 (0)