├── .github ├── dependabot.yml └── workflows │ ├── ci.yaml │ └── heroku.yml ├── .gitignore ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── cmd ├── dashboard │ └── dashboard.go └── generate-project-list │ └── main.go ├── configuration.go ├── dashboard.go ├── github.go ├── go.mod ├── go.sum ├── http.go ├── log.go ├── project.go ├── rubygems.go ├── template.go └── triage ├── template.go ├── triage.go └── types.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot automatically keeps our packages up to date 2 | # Docs: https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: gomod 7 | directory: "/" 8 | schedule: 9 | interval: daily 10 | time: "11:00" 11 | open-pull-requests-limit: 99 12 | reviewers: 13 | - parkr 14 | - package-ecosystem: github-actions 15 | directory: "/" 16 | schedule: 17 | interval: daily 18 | time: "11:00" 19 | open-pull-requests-limit: 99 20 | reviewers: 21 | - parkr 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | on: 3 | - push 4 | - pull_request 5 | name: Test 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Install Go 11 | uses: actions/setup-go@v5 12 | with: 13 | go-version: "1.23" 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | - name: Test 17 | run: go test ./... 18 | - name: Compile 19 | run: go build ./... 20 | -------------------------------------------------------------------------------- /.github/workflows/heroku.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | - deploy-with-actions 6 | name: Heroku 7 | jobs: 8 | custom-deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out repository 12 | uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 # fetch full clone 15 | - name: Install heroku CLI 16 | run: | 17 | curl https://cli-assets.heroku.com/install-ubuntu.sh | sh 18 | - name: Heroku login credentials 19 | run: | 20 | cat > ~/.netrc < repoInfos[j].Stars 99 | }) 100 | 101 | s := &strings.Builder{} 102 | err = tmpl.Execute(s, templateData{Repositories: repoInfos, Now: time.Now().UTC()}) 103 | if err != nil { 104 | log.Fatalf("unable to execute template: %v", err) 105 | } 106 | 107 | path := filepath.Join("configuration.go") 108 | err = ioutil.WriteFile(path, []byte(s.String()), 0644) 109 | if err != nil { 110 | log.Fatalf("unable to write %s: %v", path, err) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /configuration.go: -------------------------------------------------------------------------------- 1 | //go:generate go run ./cmd/generate-project-list 2 | // THIS FILE IS AUTO-GENERATED WITH 'go generate .' 3 | // LAST UPDATED Jan 27, 2023 21:24:49 UTC 4 | package dashboard 5 | 6 | var defaultProjects = []*Project{ 7 | { 8 | Name: "jekyll", 9 | Nwo: "jekyll/jekyll", 10 | Branch: "master", 11 | GemName: "jekyll", 12 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnk2NTI1Mg==", 13 | }, 14 | { 15 | Name: "minima", 16 | Nwo: "jekyll/minima", 17 | Branch: "master", 18 | GemName: "minima", 19 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnk1OTMzMTM5NQ==", 20 | }, 21 | { 22 | Name: "jekyll-admin", 23 | Nwo: "jekyll/jekyll-admin", 24 | Branch: "master", 25 | GemName: "jekyll-admin", 26 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnk1OTIyNzUyOA==", 27 | }, 28 | { 29 | Name: "jekyll-seo-tag", 30 | Nwo: "jekyll/jekyll-seo-tag", 31 | Branch: "master", 32 | GemName: "jekyll-seo-tag", 33 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnk0NDkzMTMxMw==", 34 | }, 35 | { 36 | Name: "jekyll-sitemap", 37 | Nwo: "jekyll/jekyll-sitemap", 38 | Branch: "master", 39 | GemName: "jekyll-sitemap", 40 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkxNzMyNzUzNQ==", 41 | }, 42 | { 43 | Name: "jekyll-feed", 44 | Nwo: "jekyll/jekyll-feed", 45 | Branch: "master", 46 | GemName: "jekyll-feed", 47 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkzNTQ4OTU2Nw==", 48 | }, 49 | { 50 | Name: "jekyll-redirect-from", 51 | Nwo: "jekyll/jekyll-redirect-from", 52 | Branch: "master", 53 | GemName: "jekyll-redirect-from", 54 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkxNTIwODUxNA==", 55 | }, 56 | { 57 | Name: "jekyll-compose", 58 | Nwo: "jekyll/jekyll-compose", 59 | Branch: "master", 60 | GemName: "jekyll-compose", 61 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkxOTY1MDIwNA==", 62 | }, 63 | { 64 | Name: "classifier-reborn", 65 | Nwo: "jekyll/classifier-reborn", 66 | Branch: "master", 67 | GemName: "classifier-reborn", 68 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkyMjgxODQ0MQ==", 69 | }, 70 | { 71 | Name: "jemoji", 72 | Nwo: "jekyll/jemoji", 73 | Branch: "master", 74 | GemName: "jemoji", 75 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkxNTY1NzU3OA==", 76 | }, 77 | { 78 | Name: "jekyll-import", 79 | Nwo: "jekyll/jekyll-import", 80 | Branch: "master", 81 | GemName: "jekyll-import", 82 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnk3OTIzNDQz", 83 | }, 84 | { 85 | Name: "jekyll-archives", 86 | Nwo: "jekyll/jekyll-archives", 87 | Branch: "master", 88 | GemName: "jekyll-archives", 89 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkyMzA1MTM2OQ==", 90 | }, 91 | { 92 | Name: "jekyll-gist", 93 | Nwo: "jekyll/jekyll-gist", 94 | Branch: "master", 95 | GemName: "jekyll-gist", 96 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkyMDM4MTY4Nw==", 97 | }, 98 | { 99 | Name: "github-metadata", 100 | Nwo: "jekyll/github-metadata", 101 | Branch: "main", 102 | GemName: "jekyll-github-metadata", 103 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkyNDA4ODIxNA==", 104 | }, 105 | { 106 | Name: "jekyll-mentions", 107 | Nwo: "jekyll/jekyll-mentions", 108 | Branch: "main", 109 | GemName: "jekyll-mentions", 110 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkxNjA1Mzk5NA==", 111 | }, 112 | { 113 | Name: "jekyll-sass-converter", 114 | Nwo: "jekyll/jekyll-sass-converter", 115 | Branch: "master", 116 | GemName: "jekyll-sass-converter", 117 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkxNjI0MzU3MA==", 118 | }, 119 | { 120 | Name: "mercenary", 121 | Nwo: "jekyll/mercenary", 122 | Branch: "main", 123 | GemName: "mercenary", 124 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkxNDE4MzUwNA==", 125 | }, 126 | { 127 | Name: "jekyll-paginate", 128 | Nwo: "jekyll/jekyll-paginate", 129 | Branch: "master", 130 | GemName: "jekyll-paginate", 131 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkyMDE5MjY4Ng==", 132 | }, 133 | { 134 | Name: "jekyll-watch", 135 | Nwo: "jekyll/jekyll-watch", 136 | Branch: "master", 137 | GemName: "jekyll-watch", 138 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkyMTI1NzI4Mg==", 139 | }, 140 | { 141 | Name: "jekyll-avatar", 142 | Nwo: "jekyll/jekyll-avatar", 143 | Branch: "main", 144 | GemName: "jekyll-avatar", 145 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnk0OTM3ODE0Ng==", 146 | }, 147 | { 148 | Name: "jekyll-coffeescript", 149 | Nwo: "jekyll/jekyll-coffeescript", 150 | Branch: "master", 151 | GemName: "jekyll-coffeescript", 152 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkxNjI0MjcxOA==", 153 | }, 154 | { 155 | Name: "jekyll-opal", 156 | Nwo: "jekyll/jekyll-opal", 157 | Branch: "master", 158 | GemName: "jekyll-opal", 159 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkyMDg3MjQwMw==", 160 | }, 161 | { 162 | Name: "jekyll-docs", 163 | Nwo: "jekyll/jekyll-docs", 164 | Branch: "master", 165 | GemName: "jekyll-docs", 166 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkyODU3MDA4MA==", 167 | }, 168 | { 169 | Name: "jekyll-commonmark", 170 | Nwo: "jekyll/jekyll-commonmark", 171 | Branch: "master", 172 | GemName: "jekyll-commonmark", 173 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnk0ODQwMTA3Mw==", 174 | }, 175 | { 176 | Name: "directory", 177 | Nwo: "jekyll/directory", 178 | Branch: "master", 179 | GemName: "", 180 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkxMTI2NTcwOQ==", 181 | }, 182 | { 183 | Name: "jekyll-textile-converter", 184 | Nwo: "jekyll/jekyll-textile-converter", 185 | Branch: "master", 186 | GemName: "jekyll-textile-converter", 187 | GlobalRelayID: "MDEwOlJlcG9zaXRvcnkyOTQwMzM1Ng==", 188 | }, 189 | } 190 | -------------------------------------------------------------------------------- /dashboard.go: -------------------------------------------------------------------------------- 1 | package dashboard 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/jekyll/dashboard/triage" 9 | ) 10 | 11 | var defaultPort = 8000 12 | 13 | func jsonResponse(w http.ResponseWriter, code int, body string) { 14 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 15 | w.WriteHeader(code) 16 | w.Write([]byte(body)) 17 | } 18 | 19 | func reset(w http.ResponseWriter, r *http.Request) { 20 | resetProjects() 21 | jsonResponse(w, http.StatusOK, `{"reset": "true"}`) 22 | } 23 | 24 | func show(w http.ResponseWriter, r *http.Request) { 25 | name := r.FormValue("name") 26 | if name == "" { 27 | jsonResponse(w, http.StatusBadRequest, `{"error": "missing name param"}`) 28 | return 29 | } 30 | 31 | proj := getProject(name) 32 | if proj == nil { 33 | jsonResponse(w, http.StatusNotFound, fmt.Sprintf(`{"error": "could not find project '%s'"}`, name)) 34 | return 35 | } 36 | 37 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 38 | json.NewEncoder(w).Encode(getProject(name)) 39 | } 40 | 41 | func index(w http.ResponseWriter, r *http.Request) { 42 | indexTmpl.Execute(w, templateInfo{Projects: getProjects()}) 43 | } 44 | 45 | func Listen(bindAddr string) error { 46 | // Replace init()'s with explicit inline initializations 47 | githubClient = newGitHubClient() 48 | initProjects() 49 | 50 | http.HandleFunc("/reset.json", reset) 51 | http.HandleFunc("/show.json", show) 52 | http.Handle("/triage", triage.New(githubClient, []string{"documentation", "bug", "enhancement"})) 53 | http.HandleFunc("/", index) 54 | 55 | return http.ListenAndServe(bindAddr, nil) 56 | } 57 | -------------------------------------------------------------------------------- /github.go: -------------------------------------------------------------------------------- 1 | package dashboard 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "sync" 9 | "time" 10 | 11 | gh "github.com/google/go-github/v50/github" 12 | "golang.org/x/oauth2" 13 | ) 14 | 15 | const accessTokenEnvVar = "GITHUB_ACCESS_TOKEN" 16 | const graphqlQuery = ` 17 | query jekyllFetchDashboardData($ids: [ID!]!) { 18 | nodes(ids: $ids) { 19 | ... on Repository { 20 | id 21 | owner { 22 | login 23 | } 24 | name 25 | pullRequests(states: [OPEN]) { 26 | totalCount 27 | } 28 | issues(states: [OPEN]) { 29 | totalCount 30 | } 31 | releases(first: 5, orderBy: {field: CREATED_AT, direction: DESC}) { 32 | nodes { 33 | tag { 34 | name 35 | target { 36 | __typename 37 | ... on Commit { 38 | history { 39 | totalCount 40 | } 41 | } 42 | ... on Tag { 43 | target { 44 | ... on Commit { 45 | history { 46 | totalCount 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | publishedAt 54 | isPrerelease 55 | } 56 | } 57 | defaultBranchRef { 58 | target { 59 | ... on Commit { 60 | history { 61 | totalCount 62 | nodes { 63 | statusCheckRollup { 64 | contexts(first: 100) { 65 | nodes { 66 | __typename 67 | ... on CheckRun { 68 | name 69 | status 70 | conclusion 71 | url 72 | } 73 | ... on StatusContext { 74 | description 75 | state 76 | targetUrl 77 | } 78 | } 79 | } 80 | } 81 | } 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | } 89 | ` 90 | 91 | type githubGraphQLResults struct { 92 | once sync.Once 93 | fetched bool 94 | 95 | Data struct { 96 | Nodes []struct { 97 | GlobalRelayID string `json:"id"` 98 | Owner struct { 99 | Login string `json:"login"` 100 | } `json:"owner"` 101 | Name string `json:"name"` 102 | PullRequests struct { 103 | TotalCount int `json:"totalCount"` 104 | } `json:"pullRequests"` 105 | Issues struct { 106 | TotalCount int `json:"totalCount"` 107 | } `json:"issues"` 108 | Releases struct { 109 | Nodes []struct { 110 | Tag struct { 111 | Name string `json:"name"` 112 | Target struct { 113 | TypeName string `json:"__typename"` 114 | Target struct { 115 | History struct { 116 | TotalCount int `json:"totalCount"` 117 | } `json:"history"` 118 | } `json:"target"` 119 | History struct { 120 | TotalCount int `json:"totalCount"` 121 | } `json:"history"` 122 | } `json:"target"` 123 | } `json:"tag"` 124 | PublishedAt time.Time `json:"publishedAt"` 125 | IsPreRelease bool `json:"isPrerelease"` 126 | } `json:"nodes"` 127 | } `json:"releases"` 128 | DefaultBranchRef struct { 129 | Target struct { 130 | History struct { 131 | TotalCount int `json:"totalCount"` 132 | Nodes []struct { 133 | StatusCheckRollup struct { 134 | Contexts struct { 135 | Nodes []struct { 136 | TypeName string `json:"__typename"` 137 | 138 | // On CheckRun 139 | Name string `json:"name"` 140 | Status string `json:"status"` 141 | Conclusion string `json:"conclusion"` 142 | URL string `json:"url"` 143 | 144 | // On StatusContext 145 | Description string `json:"description"` 146 | State string `json:"state"` 147 | TargetURL string `json:"targetUrl"` 148 | } `json:"nodes"` 149 | } `json:"contexts"` 150 | } `json:"statusCheckRollup"` 151 | } `json:"nodes"` 152 | } `json:"history"` 153 | } `json:"target"` 154 | } `json:"defaultBranchRef"` 155 | } `json:"nodes"` 156 | } `json:"data"` 157 | } 158 | 159 | var githubClient *gh.Client 160 | 161 | type GitHub struct { 162 | Owner string `json:"owner"` 163 | Name string `json:"name"` 164 | CommitsThisWeek int `json:"commits_this_week"` 165 | OpenPRs int `json:"open_prs"` 166 | OpenIssues int `json:"open_issues"` 167 | CommitsSinceLatestRelease int `json:"commits_since_latest_release"` 168 | LatestReleaseTag string `json:"latest_release_tag"` 169 | LatestCommitCIData []githubCIContext `json:"latest_commit_ci_data"` 170 | } 171 | 172 | type githubCIContext struct { 173 | Name string `json:"name"` 174 | State string `json:"state"` 175 | URL string `json:"url"` 176 | TypeName string `json:"__typename"` 177 | } 178 | 179 | func gitHubToken() string { 180 | return os.Getenv(accessTokenEnvVar) 181 | } 182 | 183 | func newGitHubClient() *gh.Client { 184 | if token := gitHubToken(); token != "" { 185 | return gh.NewClient(oauth2.NewClient( 186 | oauth2.NoContext, 187 | oauth2.StaticTokenSource( 188 | &oauth2.Token{AccessToken: token}, 189 | ), 190 | )) 191 | } else { 192 | log.Printf("%s required for GitHub", accessTokenEnvVar) 193 | return gh.NewClient(nil) 194 | } 195 | } 196 | 197 | func github(globalRelayID string) chan *GitHub { 198 | githubChan := make(chan *GitHub, 1) 199 | 200 | go func() { 201 | if globalRelayID == "" || githubClient == nil { 202 | githubChan <- nil 203 | close(githubChan) 204 | return 205 | } 206 | 207 | githubChan <- loadGitHubFromGraphQL(globalRelayID) 208 | close(githubChan) 209 | }() 210 | 211 | return githubChan 212 | } 213 | 214 | func loadGitHubFromGraphQL(globalRelayID string) *GitHub { 215 | githubGraphQLData := &githubGraphQLResults{} 216 | githubData := &GitHub{} 217 | 218 | err := doGraphql(githubClient, graphqlQuery, map[string]interface{}{"ids": []string{globalRelayID}}, githubGraphQLData) 219 | if err != nil { 220 | log.Printf("error fetching graphql: %+v", err) 221 | } 222 | 223 | for _, githubProject := range githubGraphQLData.Data.Nodes { 224 | if githubProject.GlobalRelayID == globalRelayID { 225 | githubData.Owner = githubProject.Owner.Login 226 | githubData.Name = githubProject.Name 227 | githubData.OpenPRs = githubProject.PullRequests.TotalCount 228 | githubData.OpenIssues = githubProject.Issues.TotalCount 229 | for _, release := range githubProject.Releases.Nodes { 230 | if !release.IsPreRelease { 231 | githubData.LatestReleaseTag = release.Tag.Name 232 | if release.Tag.Target.TypeName == "Commit" { 233 | githubData.CommitsSinceLatestRelease = githubProject.DefaultBranchRef.Target.History.TotalCount - release.Tag.Target.History.TotalCount 234 | } else { 235 | githubData.CommitsSinceLatestRelease = githubProject.DefaultBranchRef.Target.History.TotalCount - release.Tag.Target.Target.History.TotalCount 236 | } 237 | break 238 | } 239 | } 240 | 241 | index := 0 242 | if len(githubProject.DefaultBranchRef.Target.History.Nodes[1].StatusCheckRollup.Contexts.Nodes) > len(githubProject.DefaultBranchRef.Target.History.Nodes[0].StatusCheckRollup.Contexts.Nodes) { 243 | index = 1 244 | } 245 | for _, ciContext := range githubProject.DefaultBranchRef.Target.History.Nodes[index].StatusCheckRollup.Contexts.Nodes { 246 | var name, state, url string 247 | if ciContext.TypeName == "CheckRun" { 248 | name = ciContext.Name 249 | url = ciContext.URL 250 | if ciContext.Status == "pending" { 251 | state = "pending" 252 | } else { 253 | state = ciContext.Conclusion 254 | } 255 | } else { 256 | name = ciContext.Description 257 | state = ciContext.State 258 | url = ciContext.TargetURL 259 | } 260 | githubData.LatestCommitCIData = append(githubData.LatestCommitCIData, githubCIContext{ 261 | TypeName: ciContext.TypeName, 262 | Name: name, 263 | State: state, 264 | URL: url, 265 | }) 266 | } 267 | break 268 | } 269 | } 270 | 271 | return githubData 272 | } 273 | 274 | func prefillAllProjectsFromGitHub() { 275 | var wg sync.WaitGroup 276 | for _, project := range getProjects() { 277 | wg.Add(1) 278 | project := project 279 | go func() { 280 | project.fetchGitHubData() 281 | wg.Done() 282 | }() 283 | } 284 | wg.Wait() 285 | } 286 | 287 | func commitsSinceLatestRelease(owner, repo, latestReleaseTagName string) int { 288 | var comparison *gh.CommitsComparison 289 | var err error 290 | logHTTP("GET", fmt.Sprintf( 291 | "https://api.github.com/repos/%s/%s/compare/%s...master", 292 | owner, repo, latestReleaseTagName, 293 | ), func() { 294 | comparison, _, err = githubClient.Repositories.CompareCommits( 295 | context.Background(), 296 | owner, repo, 297 | latestReleaseTagName, "master", 298 | &gh.ListOptions{PerPage: 1}, // limit results so it's faster? 299 | ) 300 | }) 301 | if err != nil { 302 | log.Printf("error fetching commit comparison for %s...master for %s/%s: %v", latestReleaseTagName, owner, repo, err) 303 | return -1 304 | } 305 | return *comparison.TotalCommits 306 | } 307 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | // +heroku goVersion 1.23 2 | 3 | module github.com/jekyll/dashboard 4 | 5 | go 1.23.0 6 | 7 | toolchain go1.24.0 8 | 9 | require ( 10 | github.com/google/go-github/v50 v50.2.0 11 | golang.org/x/oauth2 v0.30.0 12 | ) 13 | 14 | require ( 15 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect 16 | github.com/cloudflare/circl v1.3.7 // indirect 17 | github.com/google/go-querystring v1.1.0 // indirect 18 | golang.org/x/crypto v0.35.0 // indirect 19 | golang.org/x/sys v0.30.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= 2 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= 3 | github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 4 | github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= 5 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= 6 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 7 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 8 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 9 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 10 | github.com/google/go-github/v50 v50.2.0 h1:j2FyongEHlO9nxXLc+LP3wuBSVU9mVxfpdYUexMpIfk= 11 | github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= 12 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 13 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 14 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 15 | golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 16 | golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 17 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 18 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 19 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 20 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 21 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 22 | golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 23 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 24 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 25 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 26 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 27 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 28 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 29 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package dashboard 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log" 7 | "net/http" 8 | "time" 9 | 10 | gh "github.com/google/go-github/v50/github" 11 | ) 12 | 13 | var allowRequestEvery = time.Second / 30 14 | var throttle <-chan time.Time = time.Tick(allowRequestEvery) 15 | 16 | func logHTTP(method, url string, f func()) { 17 | log.Println("==> ", method, url) 18 | start := time.Now() 19 | f() 20 | log.Println("==> ", method, url, "finished in", time.Since(start)) 21 | } 22 | 23 | func get(url string, data interface{}) error { 24 | <-throttle 25 | var resp *http.Response 26 | var err error 27 | logHTTP("GET", url, func() { 28 | resp, err = http.Get(url) 29 | }) 30 | if err != nil { 31 | return err 32 | } 33 | return json.NewDecoder(resp.Body).Decode(data) 34 | } 35 | 36 | func doGraphql(client *gh.Client, query string, variables map[string]interface{}, output interface{}) error { 37 | req, err := githubClient.NewRequest( 38 | "POST", 39 | "/graphql", 40 | map[string]interface{}{"query": query, "variables": variables}, 41 | ) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | logHTTP(req.Method, req.URL.String(), func() { 47 | _, err = githubClient.Do(context.Background(), req, output) 48 | }) 49 | return err 50 | } 51 | 52 | func getRetry(retries int, url string, data interface{}) error { 53 | var err error 54 | tries := 0 55 | for tries <= retries { 56 | tries += 1 57 | err = get(url, data) 58 | if err == nil { 59 | break 60 | } 61 | } 62 | return err 63 | } 64 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | //+build heroku 2 | 3 | package dashboard 4 | 5 | import "log" 6 | 7 | func init() { 8 | log.SetFlags(log.Lshortfile) 9 | } 10 | -------------------------------------------------------------------------------- /project.go: -------------------------------------------------------------------------------- 1 | package dashboard 2 | 3 | import ( 4 | "log" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | var defaultProjectMap = sync.Map{} 10 | 11 | func initProjects() { 12 | for _, p := range defaultProjects { 13 | defaultProjectMap.Store(p.Name, p) 14 | } 15 | go resetProjectsPeriodically() 16 | go prefillAllProjectsFromGitHub() 17 | } 18 | 19 | func resetProjectsPeriodically() { 20 | for range time.Tick(time.Hour / 2) { 21 | log.Println("resetting projects' cache") 22 | resetProjects() 23 | prefillAllProjectsFromGitHub() 24 | } 25 | } 26 | 27 | func resetProjects() { 28 | for _, p := range defaultProjects { 29 | p.reset() 30 | } 31 | } 32 | 33 | type Project struct { 34 | GlobalRelayID string `json:"id"` 35 | Name string `json:"name"` 36 | Nwo string `json:"nwo"` 37 | Branch string `json:"branch"` 38 | GemName string `json:"gem_name"` 39 | Stars int `json:"star_count"` 40 | 41 | Gem *RubyGem `json:"gem"` 42 | GitHub *GitHub `json:"github"` 43 | fetched bool 44 | } 45 | 46 | func (p *Project) fetch() { 47 | var wg sync.WaitGroup 48 | 49 | wg.Add(1) 50 | go func() { 51 | p.fetchGitHubData() 52 | wg.Done() 53 | }() 54 | 55 | wg.Add(1) 56 | go func() { 57 | p.fetchRubyGemData() 58 | wg.Done() 59 | }() 60 | 61 | wg.Wait() 62 | 63 | p.fetched = true 64 | } 65 | 66 | func (p *Project) fetchRubyGemData() { 67 | if p.Gem != nil { 68 | return 69 | } 70 | 71 | p.Gem = <-rubygem(p.GemName) 72 | } 73 | 74 | func (p *Project) fetchGitHubData() { 75 | if p.GitHub != nil { 76 | return 77 | } 78 | 79 | p.GitHub = <-github(p.GlobalRelayID) 80 | } 81 | 82 | func (p *Project) reset() { 83 | p.fetched = false 84 | p.Gem = nil 85 | p.GitHub = nil 86 | } 87 | 88 | func getProject(name string) *Project { 89 | if p, ok := defaultProjectMap.Load(name); ok { 90 | proj := p.(*Project) 91 | if !proj.fetched { 92 | proj.fetch() 93 | } 94 | return proj 95 | } 96 | 97 | return nil 98 | } 99 | 100 | func getProjects() []*Project { 101 | return defaultProjects 102 | } 103 | -------------------------------------------------------------------------------- /rubygems.go: -------------------------------------------------------------------------------- 1 | package dashboard 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | ) 7 | 8 | type RubyGem struct { 9 | Name string `json:"name"` 10 | Version string `json:"version"` 11 | Downloads int `json:"downloads"` 12 | HomepageURI string `json:"homepage_uri"` 13 | DocumentationURI string `json:"documentation_uri"` 14 | } 15 | 16 | func GetRubyGem(gem string) (*RubyGem, error) { 17 | if gem == "" { 18 | return nil, nil 19 | } 20 | 21 | info := &RubyGem{} 22 | err := getRetry(5, fmt.Sprintf("https://rubygems.org/api/v1/gems/%s.json", gem), info) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | return info, nil 28 | } 29 | 30 | func rubygem(gem string) chan *RubyGem { 31 | rubyGemChan := make(chan *RubyGem, 1) 32 | 33 | go func() { 34 | info, err := GetRubyGem(gem) 35 | if err != nil { 36 | log.Printf("error fetching rubygems info for %s: %v", gem, err) 37 | } 38 | rubyGemChan <- info 39 | close(rubyGemChan) 40 | }() 41 | 42 | return rubyGemChan 43 | } 44 | -------------------------------------------------------------------------------- /template.go: -------------------------------------------------------------------------------- 1 | package dashboard 2 | 3 | import ( 4 | "html/template" 5 | ) 6 | 7 | type templateInfo struct { 8 | Projects []*Project 9 | } 10 | 11 | var ( 12 | indexTmpl = template.Must(template.New("index.html").Parse(` 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | Dashboard 22 | 134 | 270 | 271 | 272 |
273 |
274 |
275 |
276 |
277 | jekyll@github.com 278 |
279 |
280 |

Dashboard

281 |
Jekyll Organization at a glance
282 |
283 |
284 |
285 |
286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | {{range .Projects}} 300 | 301 | {{end}} 302 | 303 |
RepositoryGem VersionContinuous IntegrationDownloadsPull RequestsIssuesUnreleased commits
304 |
305 | 331 |
332 | Look wrong? 333 |
334 | 335 |
336 |
337 |
338 | 421 | 422 | 423 | `)) 424 | ) 425 | -------------------------------------------------------------------------------- /triage/template.go: -------------------------------------------------------------------------------- 1 | package triage 2 | 3 | import ( 4 | "html/template" 5 | "time" 6 | 7 | "github.com/google/go-github/v50/github" 8 | ) 9 | 10 | var pretendTemplateInfo = templateInfo{ 11 | RepoName: "jekyll/test", 12 | IssueType: "issue", 13 | IssuesGroupedByLabel: []IssueGrouping{ 14 | { 15 | Label: "documentation", 16 | Issues: Issues{ 17 | github.Issue{ 18 | ID: github.Int64(100000), 19 | Number: github.Int(125), 20 | State: github.String("open"), 21 | Locked: github.Bool(true), 22 | Title: github.String("Update boolean documentation"), 23 | Labels: []*github.Label{}, 24 | User: &github.User{Login: github.String("acontributor")}, 25 | HTMLURL: github.String("https://github.com/jekyll/test/issues/125"), 26 | Assignees: []*github.User{ 27 | {Login: github.String("areviewer")}, 28 | {Login: github.String("anotherreviewer")}, 29 | }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | } 35 | 36 | type templateInfo struct { 37 | RepoName, IssueType string 38 | IssuesGroupedByLabel []IssueGrouping 39 | } 40 | 41 | func (t templateInfo) LastUpdated() string { 42 | if len(t.IssuesGroupedByLabel) == 0 { 43 | return "never" 44 | } 45 | return t.IssuesGroupedByLabel[0].lastUpdated.UTC().Format(time.UnixDate) 46 | } 47 | 48 | func (t templateInfo) Total() int { 49 | total := 0 50 | for _, grouping := range t.IssuesGroupedByLabel { 51 | total += len(grouping.Issues) 52 | } 53 | return total 54 | } 55 | 56 | var ( 57 | triageTmpl *template.Template 58 | 59 | triageTmplText = ` 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | Dashboard Triage 69 | 73 | 74 | 75 | 76 |
{{.RepoName}} {{.IssueType}} triage
 77 | Last updated {{.LastUpdated}}
 78 | 
 79 | {{.Total}} pending {{.IssueType}}s
 80 | 
 81 | Filter by using the type (issue/pr/all), label (see below), or repo (nwo, e.g. "jekyll/jekyll") URL parameters. You can reorder by creation date using order key (asc/desc). Example: view only "documentation" issues starting with newest.
 82 | 
 83 | 
Pending {{.IssueType}}s 84 | 85 | {{range .IssuesGroupedByLabel}} 86 | {{.Label}} ({{len .Issues}} issues){{range .Issues}} 87 | {{issueType .}} {{.Number}}{{printf "\t"}}{{.Title}} 88 | {{.User.GetLogin}} →{{range .Assignees}} {{.Login}}{{end}}{{if len .Assignees | eq 0}} ???{{end}}, {{daysAgo .GetUpdatedAt}}/{{daysAgo .GetCreatedAt}} days, waiting for {{if hasLabel . "pending-feedback"}}author{{else}}reviewer{{end}} 89 | {{end}} 90 | {{if len .Issues | eq 0}}no issues!{{end}} 91 | {{end}} 92 |
93 | 94 | 95 | 96 | ` 97 | ) 98 | 99 | func initTemplates() { 100 | var err error 101 | triageTmpl, err = template.New("triage").Funcs(template.FuncMap{ 102 | "daysAgo": daysAgo, 103 | "hasLabel": hasLabel, 104 | "issueType": issueTypeForIssue, 105 | }).Parse(triageTmplText) 106 | if err != nil { 107 | panic(err) 108 | } 109 | } 110 | 111 | func daysAgo(t time.Time) int { 112 | return int(time.Now().Sub(t).Seconds() / 86400) 113 | } 114 | 115 | func hasLabel(issue github.Issue, desired string) bool { 116 | for _, label := range issue.Labels { 117 | if label.GetName() == desired { 118 | return true 119 | } 120 | } 121 | return false 122 | } 123 | 124 | func issueTypeForIssue(issue github.Issue) template.HTML { 125 | if issue.PullRequestLinks != nil { 126 | return `` 127 | } 128 | return `` 129 | } 130 | -------------------------------------------------------------------------------- /triage/triage.go: -------------------------------------------------------------------------------- 1 | package triage 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "sort" 8 | "sync" 9 | "time" 10 | 11 | "github.com/google/go-github/v50/github" 12 | ) 13 | 14 | var once sync.Once 15 | 16 | func New(client *github.Client, labelsofInterest []string) *Triager { 17 | once.Do(initTemplates) 18 | return &Triager{ 19 | Client: client, 20 | LabelsOfInterest: labelsofInterest, 21 | repoTypeCache: map[string][]IssueGrouping{}, 22 | } 23 | } 24 | 25 | type Triager struct { 26 | Client *github.Client 27 | LabelsOfInterest []string 28 | 29 | repoTypeCache map[string][]IssueGrouping 30 | } 31 | 32 | func (t *Triager) ServeHTTP(w http.ResponseWriter, r *http.Request) { 33 | if r.FormValue("reset") != "" { 34 | t.repoTypeCache = map[string][]IssueGrouping{} 35 | log.Println("resetting repoTypeCache...") 36 | http.Redirect(w, r, "/triage", 302) 37 | return 38 | } 39 | 40 | repo := r.FormValue("repo") 41 | if repo == "" { 42 | repo = "jekyll/jekyll" 43 | } 44 | 45 | issueType := r.FormValue("type") 46 | if issueType == "" { 47 | issueType = "all" 48 | } 49 | 50 | label := r.FormValue("label") 51 | // If blank, then we pull all of them. 52 | 53 | order := r.FormValue("order") 54 | // If blank, then we don't do any sorting. 55 | 56 | err := triageTmpl.Execute(w, t.getTemplateInfo(repo, issueType, label, order)) 57 | if err != nil { 58 | w.Write([]byte(err.Error())) 59 | } 60 | } 61 | 62 | func (t *Triager) getTemplateInfo(repo, issueType, label, order string) templateInfo { 63 | key := repo + "____" + issueType 64 | if _, ok := t.repoTypeCache[key]; !ok { 65 | t.repoTypeCache[key] = t.fetchIssues(repo, issueType) 66 | } 67 | 68 | desiredGroupings := []IssueGrouping{} 69 | if label == "" { 70 | desiredGroupings = t.repoTypeCache[key] 71 | } else { 72 | for _, labelGrouping := range t.repoTypeCache[key] { 73 | if labelGrouping.Label == label { 74 | desiredGroupings = append(desiredGroupings, labelGrouping) 75 | break 76 | } 77 | } 78 | } 79 | 80 | if issueType == "all" { 81 | issueType = "issues & pull request" 82 | } 83 | 84 | if order != "" { 85 | log.Printf("Ordering issues by CreatedAt %s", order) 86 | for _, labelGrouping := range desiredGroupings { 87 | if order == "desc" { 88 | // Sorts them in descending order by CreatedAt date. 89 | sort.Stable(sort.Reverse(labelGrouping.Issues)) 90 | } else { 91 | // Sorts them in ascending order by CreatedAt date. 92 | sort.Stable(labelGrouping.Issues) 93 | } 94 | } 95 | } 96 | 97 | return templateInfo{ 98 | RepoName: repo, 99 | IssueType: issueType, 100 | IssuesGroupedByLabel: desiredGroupings, 101 | } 102 | } 103 | 104 | func (t Triager) fetchIssues(repo, issueType string) []IssueGrouping { 105 | log.Printf("Fetching issues of type %s for %s", issueType, repo) 106 | 107 | // Get all issues or pull requests (depending on issueType) for repo 108 | issues := []*github.Issue{} 109 | query := "repo:" + repo + " is:open" 110 | if issueType != "" && issueType != "all" { 111 | query += " is:" + issueType 112 | } 113 | opts := &github.SearchOptions{ 114 | Sort: "created", 115 | Order: "asc", 116 | ListOptions: github.ListOptions{PerPage: 500}, 117 | } 118 | for { 119 | log.Printf("Running query %q page %d", query, opts.ListOptions.Page) 120 | result, resp, err := t.Client.Search.Issues(context.Background(), query, opts) 121 | if err != nil { 122 | return []IssueGrouping{{Label: "error: " + err.Error()}} 123 | } 124 | 125 | for _, issue := range result.Issues { 126 | issues = append(issues, issue) 127 | } 128 | 129 | if resp.NextPage == 0 { 130 | break 131 | } 132 | opts.ListOptions.Page = resp.NextPage 133 | } 134 | 135 | // Create groupings 136 | triageGroup := &IssueGrouping{Label: "triage", Issues: Issues{}} 137 | grouping := []*IssueGrouping{} 138 | for _, label := range t.LabelsOfInterest { 139 | grouping = append(grouping, &IssueGrouping{Label: label, Issues: Issues{}}) 140 | } 141 | 142 | // Group by each label of interest. 143 | for _, issue := range issues { 144 | matchedALabelGroup := false 145 | for _, labelGroup := range grouping { 146 | for _, label := range issue.Labels { 147 | if label.GetName() == labelGroup.Label { 148 | labelGroup.Issues = append(labelGroup.Issues, *issue) 149 | matchedALabelGroup = true 150 | break 151 | } 152 | } 153 | } 154 | // If it didn't match another grouping label, then it's not been properly triaged. 155 | if matchedALabelGroup == false { 156 | triageGroup.Issues = append(triageGroup.Issues, *issue) 157 | } 158 | } 159 | 160 | triageGroup.lastUpdated = time.Now() 161 | unmodifiable := []IssueGrouping{*triageGroup} 162 | for _, group := range grouping { 163 | group.lastUpdated = time.Now() 164 | unmodifiable = append(unmodifiable, *group) 165 | } 166 | 167 | for _, labelGroup := range unmodifiable { 168 | log.Printf("Label group %q has %d issues", labelGroup.Label, len(labelGroup.Issues)) 169 | } 170 | 171 | log.Printf("Done fetching issues of type %s for %s... found %d issues", issueType, repo, len(issues)) 172 | 173 | return unmodifiable 174 | } 175 | -------------------------------------------------------------------------------- /triage/types.go: -------------------------------------------------------------------------------- 1 | package triage 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/go-github/v50/github" 7 | ) 8 | 9 | type IssueGrouping struct { 10 | Label string 11 | Issues Issues 12 | 13 | lastUpdated time.Time 14 | } 15 | 16 | type Issues []github.Issue 17 | 18 | func (g Issues) Len() int { 19 | return len(g) 20 | } 21 | func (g Issues) Swap(i, j int) { 22 | g[i], g[j] = g[j], g[i] 23 | } 24 | func (g Issues) Less(i, j int) bool { 25 | return g[i].GetCreatedAt().Before(g[j].GetCreatedAt().Time) 26 | } 27 | --------------------------------------------------------------------------------