Check your GitHub news in one table

by about Go, Management in Technology

Dashpanel is just the thing for when you really want to support your community but lost track of what’s happening across your repositories.

A while ago I posted about a GitHub inventory for Google Sheets that is a handy tool for people who want to keep track of what is going on across several GitHub organizations within just a few minutes of setup.

If you need to go a bit further and integrate something similar on GitHub itself, there are of course plenty of tools available for that. Since I tend to avoid dependencies whereever I can, especially for something simple like querying an API, I wrote Dashpanel and put it on the GitHub Marketplace, which means you can quickly integrate it into your GitHub Actions workflows.

What is Dashpanel? ▲ Back to top

Dashpanel is a quick way for you to get an overview of stars, forks as well as open issues and pull requests over any GitHub organizations’ repositories where you have access.

We are using it within the oVirt organization to keep track of community issues.

Screenshot of oVirt Dashpanel

Why is it so ugly? ▲ Back to top

It’s meant to be a quick way of providing an overview without any dependencies other than the Go standard library. If you want it to be prettier, feel free to fork the repository and add plenty of formatting and sparkles. Please also let us know what you’ve created!

How can I use it? ▲ Back to top

If you just want to test it right now, clone the Dashpanel repository and, assuming you have Go installed, run the application from the terminal using one or more orgname of your choice.

go run main.go orgname1 orgname2 orgname3 orgnameN

You can also use it via containers (described in the repository README) and GitHub Actions (described on Marketplace).

Show me the code ▲ Back to top

Dashpanel uses a Markdown template via Go’s html/template package. The template looks as follows.

{{ $orgname := .Orgname }}
# Dashpanel - {{ $orgname }}

| Repository | Description | Issues & PRs | Starred | Forks |
|---|---|---|---|---|
{{ range .Repos -}}{{ if not .Private -}}{{ if not .Archived -}}{{ if gt .OpenIssuesCount 0 -}}
| [{{ .Name }}]({{ .Url }}) | {{ .Description }} | [{{ .OpenIssuesCount }}](https://github.com/{{- $orgname -}}/{{- .Name -}}/issues) | {{ .StargazersCount }} | {{ .ForksCount }} |
{{ end }}{{ end }}{{ end }}{{ end }}

Yes, it’s totally beautiful markdown code. 🤦

The first column in this table will output the repository name and link it. You could remove the {{ if not .Private -}} and {{ if not .Archived -}} conditions in order to include private and archived repositories. If you want to include all repositories and not just the ones who have open issues or PRs, you will also need to remove the {{ if gt .OpenIssuesCount 0 -}} condition.

You might notice that the link to the issues and PRs just shows issues. Dashpanel does not differentiate between them as it is meant for larger organizations where the GitHub API would hit rate limits very soon. If you wanted a differentiation and your organization is small enough, you could query each repository and then can easily separate issues and PRs. For bigger or multiple organizations, you would have to host your own server and keep track of the repositories over time to avoid GitHub’s API rate limits.

For our purposes, we will use the issues URL for each repository, knowing that the number includes both issues and PRs.

Now let’s move to the Go parts of Dashpanel. We’re looking at less than 115 lines altogether, a third of which consists of Go standard library imports and struct definitions from JSON attributes we get from GitHub’s API. The TemplateData struct with its Repos slice is important when we loop over the repository contents to fill the markdown table.

package main

import (
	_ "embed"
	"encoding/json"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"os"
	"sort"
	"strconv"
)

type Repository struct {
	Name            string `json:"name,omitempty"`
	Description     string `json:"description,omitempty"`
	Url             string `json:"html_url,omitempty"`
	ForksCount      int    `json:"forks_count,omitempty"`
	OpenIssuesCount int    `json:"open_issues_count,omitempty"`
	StargazersCount int    `json:"stargazers_count,omitempty"`
	Private         bool   `json:"private,omitempty"`
	Archived        bool   `json:"archived,omitempty"`
}

type TemplateData struct {
	Orgname string
	Repos   []Repository
}

We need the _ “embed” line to bake the template into the Go binary at the end. One important feature of this code is the pagination. We increment the page variable until len(content) returns nothing and we can therefore be sure that we covered all the pages.

//go:embed template.md
var tmpl string

func showListOpenIssues(orgname string) {
	client := &http.Client{}

	url := "https://api.github.com/orgs/" + orgname + "/repos"
	page := 1
	data := TemplateData{Orgname: orgname, Repos: nil}
	layout, err := template.New("Template").Parse(tmpl)

	for {
        
		pagination := "?page=" + strconv.Itoa(page) + "&per_page=100"
		request, err := http.NewRequest("GET", url+pagination, nil)
		if err != nil {
			log.Fatalln(err)
		}

		// gets authentication token from environment variable
		if pat := os.Getenv("GITHUB_TOKEN"); pat != "" {
			request.Header.Set("Authorization", "Bearer "+pat)
		}

		response, err := client.Do(request)
		if err != nil {
			log.Fatalln(err)
		}

		defer response.Body.Close()

		decoder := json.NewDecoder(response.Body)
		var content []Repository
		err = decoder.Decode(&content)

		 if len(content) == 0 {
			break
		}
		if err != nil {
			log.Fatalln(err)
		}
		if err != nil {
			log.Fatal("data file could not be created (%w)", err)
		}
		if err != nil {
			log.Fatal("couldn't parse template (%w)", err)
		}
    	page++
		data.Repos = append(data.Repos, content...)
	}

	// sorts repositories by number of open issues and PRs
	sort.Slice(data.Repos, func(i, j int) bool {
		return data.Repos[i].OpenIssuesCount > data.Repos[j].OpenIssuesCount
	})

	// decodes JSON response data into output file
	filename := fmt.Sprintf("dashpanel-%s.md", orgname)
	dashpanel, err := os.Create(filename)
	err = layout.Execute(dashpanel, data)

	if err != nil {
		log.Fatal("couldn't execute on the template: (%w)", err)
	}
}

We’re almost done. All we need to do is to let the application be run using multiple orgname arguments.

func main() {
	if len(os.Args) < 2 {
		log.Fatal("please provide an organization name when running this application")
	}

	for _, orgname := range os.Args[1:] {
		showListOpenIssues(orgname)
	}
}

That’s the whole code - no dependencies and no sparkles, but it works reliably, you can extend it, and it can easily be integrated into your GitHub workflows. When testing this locally for any private repositories, you will need to set an environment variable using your GitHub token.

What’s next? ▲ Back to top

I might add a check that counts organization parameters and then separates issues and PRs if the number of repositories does not pass the rate limit threshold. If that sounds like a useful addition to you or if you have other ideas, let me know via the dashpanel issues.