1
0
Fork 0
mirror of https://github.com/Luzifer/clean-github-branches.git synced 2024-11-08 14:10:04 +00:00
clean-github-branches/main.go
Knut Ahlers dd8da5cac4
Log branch age
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2018-09-07 12:06:53 +02:00

337 lines
10 KiB
Go

package main
import (
"context"
"fmt"
"math"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/Luzifer/go_helpers/duration"
"github.com/Luzifer/rconfig"
"github.com/google/go-github/github"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"golang.org/x/oauth2"
)
const humanizeTemplate = `{{if gt .Years 0}}{{.Years}}y{{end}}` +
`{{if gt .Days 0}}{{.Days}}d{{end}}` +
`{{if gt .Hours 0}}{{.Hours}}h{{end}}`
var (
cfg = struct {
BranchStaleness time.Duration `flag:"branch-staleness" default:"2160h" description:"When to see a branch as stale (default 90d)"`
DeleteStale bool `flag:"delete-stale" default:"false" description:"Delete branches after branch-staleness even if ahead of base"`
DryRun bool `flag:"dry-run,n" default:"true" description:"Do a dry-run (take no destructive action)"`
EvenTimeout time.Duration `flag:"even-timeout" default:"24h" description:"When to delete a branch which is not ahead of base"`
GithubToken string `flag:"token" default:"" description:"Token to access Github API"`
Listen string `flag:"listen" default:":3000" description:"Port/IP to listen on"`
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
RepoProcessLimit int `flag:"process-limit" default:"10" description:"How many repos to process concurrently"`
RepoRegex string `flag:"repo-regex,r" default:"" description:"Regular expression the full repo (user/reponame) must match against"`
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
}{}
repoRegex *regexp.Regexp
version = "dev"
)
func init() {
rconfig.AutoEnv(true)
if err := rconfig.ParseAndValidate(&cfg); err != nil {
log.Fatalf("Unable to parse commandline options: %s", err)
}
if cfg.VersionAndExit {
fmt.Printf("git-changerelease %s\n", version)
os.Exit(0)
}
if l, err := log.ParseLevel(cfg.LogLevel); err != nil {
log.WithError(err).Fatal("Unable to parse log level")
} else {
log.SetLevel(l)
}
}
func main() {
var err error
if repoRegex, err = regexp.Compile(cfg.RepoRegex); err != nil {
log.WithError(err).Fatal("Repo-Regex does not compile")
}
ctx := context.Background()
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: cfg.GithubToken},
)
tc := oauth2.NewClient(ctx, ts)
client := github.NewClient(tc)
var (
logger = log.WithField("dry-run", cfg.DryRun)
repos = make(chan *github.Repository, 100)
wg = new(sync.WaitGroup)
)
defer close(repos)
// Process repos found
go repoWalk(ctx, logger, client, wg, repos)
// Find repos for users
wg.Add(1)
go findRepos(ctx, logger, client, wg, repos)
wg.Wait()
log.Info("Done.")
}
func analysePullRequests(logger *log.Entry, prs []*github.PullRequest, b *github.Branch) (hasValidMerge, hasOpenPR bool) {
for _, pr := range prs {
if pr.GetHead().GetSHA() != b.GetCommit().GetSHA() {
// Does not seem to be the PR for this branch
continue
}
if pr.GetState() == "open" {
// Is an open PR, don't touch
logger.Debug("Branch has an open PR")
hasOpenPR = true
continue
}
if !pr.GetMerged() && pr.GetMergeCommitSHA() == "" {
// Is not merged but closed: Don't touch! That's too hot...
logger.Warn("Branch has a closed but not merged PR")
continue
}
if pr.GetMerged() || pr.GetMergeCommitSHA() != "" {
hasValidMerge = true
}
}
return hasValidMerge, hasOpenPR
}
func deleteBranch(ctx context.Context, logger *log.Entry, client *github.Client, repo *github.Repository, b *github.Branch, reason string) {
logger.WithField("reason", reason).Info("Deleting branch")
if !cfg.DryRun {
if _, err := client.Git.DeleteRef(ctx, repo.Owner.GetLogin(), repo.GetName(), strings.Join([]string{"heads", b.GetName()}, "/")); err != nil {
logger.WithError(err).Error("Could not delete branch")
}
}
}
func fetchBranchesForRepo(ctx context.Context, client *github.Client, repo *github.Repository) ([]*github.Branch, error) {
var (
branches = []*github.Branch{}
lo = github.ListOptions{}
)
// Fetch all branches for the repo
for {
b, resp, err := client.Repositories.ListBranches(ctx, repo.Owner.GetLogin(), repo.GetName(), &lo)
if err != nil {
return nil, errors.Wrap(err, "Could not fetch branches")
}
branches = append(branches, b...)
if resp.NextPage == 0 {
break
}
lo.Page = resp.NextPage
}
return branches, nil
}
func fetchPullRequestsForBranch(ctx context.Context, client *github.Client, repo *github.Repository, b *github.Branch) ([]*github.PullRequest, error) {
var (
prs = []*github.PullRequest{}
lo = github.ListOptions{}
)
fetch := func(owner, name string) error {
for {
p, resp, err := client.PullRequests.List(ctx, owner, name, &github.PullRequestListOptions{
State: "all",
Head: strings.Join([]string{repo.Owner.GetLogin(), b.GetName()}, ":"),
Base: repo.GetDefaultBranch(),
ListOptions: lo,
})
if err != nil {
return err
}
prs = append(prs, p...)
if resp.NextPage == 0 {
break
}
lo.Page = resp.NextPage
}
return nil
}
if err := fetch(repo.Owner.GetLogin(), repo.GetName()); err != nil {
return nil, errors.Wrap(err, "Could not fetch pull-requests")
}
if repo.GetFork() {
// Re-fetch the repo in order to have parent information included
var err error
if repo, _, err = client.Repositories.Get(ctx, repo.Owner.GetLogin(), repo.GetName()); err != nil {
return nil, errors.Wrap(err, "Could not update repo to fetch parent information")
}
if err := fetch(repo.GetParent().GetOwner().GetLogin(), repo.GetParent().GetName()); err != nil {
return nil, errors.Wrap(err, "Could not fetch pull-requests")
}
}
return prs, nil
}
func findRepos(ctx context.Context, logger *log.Entry, client *github.Client, wg *sync.WaitGroup, repos chan *github.Repository) {
defer wg.Done()
var (
userRepos []*github.Repository
lo = github.ListOptions{}
)
for {
ur, resp, err := client.Repositories.List(ctx, "", &github.RepositoryListOptions{
ListOptions: lo,
})
if err != nil {
logger.WithError(err).Error("Unable to fetch repos")
return
}
userRepos = append(userRepos, ur...)
if resp.NextPage == 0 {
break
}
lo.Page = resp.NextPage
}
wg.Add(len(userRepos))
for _, r := range userRepos {
if !repoRegex.MatchString(r.GetFullName()) {
logger.WithField("repo", r.GetFullName()).Debug("Repo is filtered by user parameter")
wg.Done()
continue
}
if r.GetArchived() {
logger.WithField("repo", r.GetFullName()).Debug("Repo is archived, not processing")
wg.Done()
continue
}
repos <- r
}
}
func processRepo(ctx context.Context, logger *log.Entry, client *github.Client, repo *github.Repository) error {
logger.Debug("Fetching branches")
branches, err := fetchBranchesForRepo(ctx, client, repo)
if err != nil {
return err
}
// Iterate and look at every branch
for _, b := range branches {
branchLogger := logger.WithField("branch", b.GetName())
// Do never touch the default branch
if b.GetName() == repo.GetDefaultBranch() {
branchLogger.Debug("Default branch, don't touch")
continue
}
// Fetch full branch information containing commit information
if b, _, err = client.Repositories.GetBranch(ctx, repo.Owner.GetLogin(), repo.GetName(), b.GetName()); err != nil {
branchLogger.WithError(err).Error("Could not update branch to fetch commit info")
continue
}
// Fetch all PRs for this repo and if it is a fork also for the fork
prs, err := fetchPullRequestsForBranch(ctx, client, repo, b)
if err != nil {
branchLogger.WithError(err).Error("Could not fetch pull-requests")
continue
}
// Check whether the branch is ahead of the base branch
branchLogger.WithFields(log.Fields{"base": repo.GetDefaultBranch(), "head": b.GetName()}).Debug("Comparing branch")
comp, _, err := client.Repositories.CompareCommits(ctx, repo.Owner.GetLogin(), repo.GetName(), repo.GetDefaultBranch(), b.GetName())
if err != nil {
branchLogger.WithError(err).Error("Could not compare branches")
continue
}
branchLogger = branchLogger.WithFields(log.Fields{"ahead": comp.GetAheadBy(), "behind": comp.GetBehindBy()})
branchLogger.Debug("Comparison successful")
// Determine when the last commit was authored and committed, take newer date
branchLastModified := time.Duration(math.Min(
float64(time.Since(b.GetCommit().GetCommit().GetAuthor().GetDate())),
float64(time.Since(b.GetCommit().GetCommit().GetCommitter().GetDate())),
))
d, _ := duration.CustomHumanizeDuration(branchLastModified, humanizeTemplate)
branchLogger = branchLogger.WithField("age", d)
// Check all PRs whether they match the branch (head) and are merged
hasValidMerge, hasOpenPR := analysePullRequests(branchLogger, prs, b)
if hasOpenPR {
continue
}
switch {
case hasValidMerge:
deleteBranch(ctx, branchLogger, client, repo, b,
"PR exists and is merged")
case comp.GetAheadBy() == 0 && branchLastModified > cfg.EvenTimeout:
deleteBranch(ctx, branchLogger, client, repo, b,
"Branch is even and older than even-timeout")
case comp.GetAheadBy() > 0 && cfg.DeleteStale && branchLastModified > cfg.BranchStaleness:
deleteBranch(ctx, branchLogger, client, repo, b,
"Branch is stale and delete-stale is set")
case comp.GetAheadBy() > 0 && !cfg.DeleteStale && branchLastModified > cfg.BranchStaleness:
// Warn target when stale branches are not to be deleted
branchLogger.Warn("Stale branch found")
default:
branchLogger.Debug("No reason for deletion found")
}
}
return nil
}
func repoWalk(ctx context.Context, logger *log.Entry, client *github.Client, wg *sync.WaitGroup, repos <-chan *github.Repository) {
limiter := make(chan struct{}, cfg.RepoProcessLimit)
for repo := range repos {
limiter <- struct{}{}
go func(ctx context.Context, logger *log.Entry, client *github.Client, wg *sync.WaitGroup, repo *github.Repository, limiter <-chan struct{}) {
defer func() {
<-limiter
wg.Done()
}()
repoLogger := logger.WithField("repo", repo.GetFullName())
if err := processRepo(ctx, repoLogger, client, repo); err != nil {
repoLogger.WithError(err).Error("Repo processing caused an error")
}
}(ctx, logger, client, wg, repo, limiter)
}
}