mirror of
https://github.com/Luzifer/clean-github-branches.git
synced 2024-12-22 10:21:19 +00:00
Initial version
This commit is contained in:
commit
d858f77afa
2 changed files with 332 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
clean-github-branches
|
||||
.env
|
330
main.go
Normal file
330
main.go
Normal file
|
@ -0,0 +1,330 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Luzifer/rconfig"
|
||||
|
||||
"github.com/google/go-github/github"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
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())),
|
||||
))
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue