Skip to content

Commit

Permalink
Merge pull request #1 from pcanilho/develop
Browse files Browse the repository at this point in the history
release(1.0.4):
  • Loading branch information
pcanilho authored Sep 4, 2023
2 parents 6ee0a7e + 57994eb commit dd80fc2
Show file tree
Hide file tree
Showing 17 changed files with 677 additions and 116 deletions.
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
name: ci
on:
push:
branches: [ '**' ]
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: |
go test ./... -test.v
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ permissions:
contents: write

jobs:
ci:
uses: ./.github/workflows/ci.yml
release:
runs-on: ubuntu-latest
needs: [ ci ]
steps:
- uses: actions/checkout@v3
- uses: cli/gh-extension-precompile@v1
38 changes: 28 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,40 @@
# GitHub `tidy` 🧹extension

[![Actions Status: Release](https://github.com/pcanilho/gh-tidy/workflows/release/badge.svg)](https://github.com/pcanilho/gh-tidy/actions?query=release)

The `gh-tidy` project is an extension for the standard `gh` cli that aims at offering tidy/cleanup operations on existing `refs`
(in either `branch` or `PR` format) by providing rules, such as `stale` status based on last commit date for a given branch, PR activity and others.

🚀 This project is entirely built upon GitHub's `graphql` API offered via the https://github.com/shurcooL/githubv4 project.
[![CI: Tests](https://github.com/pcanilho/gh-tidy/workflows/ci/badge.svg)](https://github.com/pcanilho/gh-tidy/actions?query=ci)
[![CD: Release](https://github.com/pcanilho/gh-tidy/workflows/release/badge.svg)](https://github.com/pcanilho/gh-tidy/actions?query=release)

The `gh-tidy` project is a tiny & simple extension for the standard `gh` cli that aims at offering tidy/cleanup operations on existing `refs`
(in either `branch`, `tag` or `PR` format) by providing rules, such as `stale` status based on HEAD commit date for a given branch, tag, PR activity and others.

🚀 Supports:
* **Enterprise** and **Public** GitHub API endpoints are supported.
* **Automatic authentication** using the environment variable `GITHUB_TOKEN`.
* **Automatic** GitHub API **limit handling** where requests are restarted after the `X-RateLimit-Reset` timer expires.
* **Automatic** API **batching** to avoid unnecessary collisions with the internal API (_defaults to `20`_).
* **Listing** & **Deletion** of branches with a stale HEAD commit based on time duration.
* **Listing** & **Deletion** of tags with a stale commit based on time duration.
* **Closing** of PRs with a stale branch HEAD commit based on time duration & PR state.

ℹ️ This is a utility project that I have been extending when needed on a best-effort basis. Feel free to contribute with a PR
or open an Issue on GitHub!

📝 TODOs (for lack of time...):
* API:
* Support GitHub APP `pem` direct authentication.
* [stale] Branches:
* Support optional detected if the provided branch has already been merged to the repository default branch.

---

## Using `gh-tidy`
0. <ins>Expose</ins> a `GITHUB_TOKEN` environment variable with `repo:read` privileges or `repo:write` if you wish to use the `Delete` features. (*)
## Using `gh-tidy`
_...**locally** or through a CI system like **Jenkins**, **GitHub actions** or any other..._
0. <ins>Expose</ins> a `GITHUB_TOKEN` environment variable with `repo:read` privileges or `repo:admin` if you wish to use the `delete` features. (*)
1. <ins>Install</ins> the `gh` cli available [here](https://github.com/cli/cli#installation).
2. <ins>Install</ins> the extension:
```shell
$ gh extension install pcanilho/gh-tidy
```
or <ins>upgrade</ins> to `latest` version:
or <ins>upgrade</ins> to the `latest` version:
```shell
$ gh extension upgrade pcanilho/gh-tidy
```
Expand All @@ -25,7 +43,7 @@ The `gh-tidy` project is an extension for the standard `gh` cli that aims at off
$ gh tidy --help
```

\* This can be a `PAT`, a GitHub App installation `access_token` or any other format compatible with the `oauth2.StaticTokenSource` OAuth2 client.
\* This can be a `PAT`, a GitHub App installation `access_token` or any other format that allows API access via `Bearer` token.

**Note**: Authentication through direct GitHub App PEM is not (yet) supported.
### Usage
Expand Down
219 changes: 173 additions & 46 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,104 @@ package api

import (
"context"
"errors"
"fmt"
"github.com/google/go-github/github"
"github.com/pcanilho/gh-tidy/models"
"github.com/pcanilho/gh-tidy/helpers"
"github.com/shurcooL/githubv4"
"golang.org/x/oauth2"
"net/http"
"os"
"strings"
"sync"
"time"
)

const _defaultWorkerCount = 20

type GitHub struct {
clientV3 *github.Client
clientV4 *githubv4.Client
enterpriseEndpoint string
clientV3 *github.Client
clientV4 *githubv4.Client

httpClient *http.Client
context context.Context
workerCount int
}

type Option = func(*GitHub)

func WithHttpClient(httpClient *http.Client) Option {
return func(session *GitHub) {
session.httpClient = httpClient
}
}

func WithContext(ctx context.Context) Option {
return func(session *GitHub) {
session.context = ctx
}
}

func WithEnterpriseEndpoint(enterpriseEndpoint string) Option {
return func(session *GitHub) {
session.enterpriseEndpoint = enterpriseEndpoint
}
}

func WithWorkerCount(workerCount int) Option {
return func(session *GitHub) {
session.workerCount = workerCount
}
}

func NewSession() (*GitHub, error) {
func NewSession(opts ...Option) (*GitHub, error) {
inst := new(GitHub)
for _, opt := range opts {
opt(inst)
}

if inst.context == nil {
inst.context = context.Background()
}

if inst.workerCount == 0 {
inst.workerCount = _defaultWorkerCount
}

token := os.Getenv("GITHUB_TOKEN")
if len(strings.TrimSpace(token)) == 0 {
return nil, fmt.Errorf("a GITHUB_TOKEN environment variable needs to be set")
}

ctx := context.Background()
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: token},
)
tc := oauth2.NewClient(ctx, ts)
return &GitHub{clientV3: github.NewClient(tc), clientV4: githubv4.NewClient(tc)}, nil
if inst.httpClient == nil {
ghRoundTripper, err := helpers.NewGitHubRoundTripper(inst.context, token)
if err != nil {
return nil, err
}
inst.httpClient = ghRoundTripper.OauthClient
}
if len(inst.enterpriseEndpoint) != 0 {
clientV3, err := github.NewEnterpriseClient(inst.enterpriseEndpoint, inst.enterpriseEndpoint, inst.httpClient)
if err != nil {
return nil, err
}
inst.clientV3 = clientV3
inst.clientV4 = githubv4.NewEnterpriseClient(inst.enterpriseEndpoint, inst.httpClient)
} else {
inst.clientV3 = github.NewClient(inst.httpClient)
inst.clientV4 = githubv4.NewClient(inst.httpClient)
}
return inst, nil
}
func (gh *GitHub) ListPRs(ctx context.Context, states []string, owner, repo string) ([]*models.GitHubPR, error) {
func (gh *GitHub) ListPRs(ctx context.Context, states []string, owner, repo string) ([]*GitHubPR, error) {
if len(owner) == 0 {
return nil, fmt.Errorf("an owner must be specified")
}

if len(repo) == 0 {
return nil, fmt.Errorf("a repo must be specified")
}

var query struct {
Repository struct {
PullRequests struct {
Expand All @@ -50,7 +119,11 @@ func (gh *GitHub) ListPRs(ctx context.Context, states []string, owner, repo stri
BaseRefName string
HeadRefName string
}
} `graphql:"pullRequests(states: $states, last: $last)"`
PageInfo struct {
EndCursor string
HasNextPage bool
}
} `graphql:"pullRequests(first: $first, after: $after, states: $states)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}

Expand All @@ -61,24 +134,31 @@ func (gh *GitHub) ListPRs(ctx context.Context, states []string, owner, repo stri
variables := map[string]interface{}{
"owner": githubv4.String(owner),
"name": githubv4.String(repo),
"last": githubv4.Int(100),
"first": githubv4.Int(100),
"after": (*githubv4.String)(nil),
"states": sts,
}
var out []*GitHubPR

if err := gh.clientV4.Query(ctx, &query, variables); err != nil {
return nil, err
}
for {
if err := gh.clientV4.Query(ctx, &query, variables); err != nil {
return nil, err
}

var out []*models.GitHubPR
for _, pr := range query.Repository.PullRequests.Nodes {
out = append(out, &models.GitHubPR{
Source: pr.HeadRefName,
Target: pr.BaseRefName,
LastCommitDate: pr.Commits.Nodes[0].Commit.CommittedDate,
Id: pr.Id,
Number: pr.Commits.Nodes[0].PullRequest.Number,
Url: pr.Commits.Nodes[0].PullRequest.Url,
})
for _, pr := range query.Repository.PullRequests.Nodes {
out = append(out, &GitHubPR{
Source: pr.HeadRefName,
Target: pr.BaseRefName,
LastCommitDate: pr.Commits.Nodes[0].Commit.CommittedDate,
Id: pr.Id,
Number: pr.Commits.Nodes[0].PullRequest.Number,
Url: pr.Commits.Nodes[0].PullRequest.Url,
})
}
if !query.Repository.PullRequests.PageInfo.HasNextPage {
break
}
variables["after"] = githubv4.String(query.Repository.PullRequests.PageInfo.EndCursor)
}
return out, nil
}
Expand All @@ -90,7 +170,15 @@ const (
TagRefType = "refs/tags/"
)

func (gh *GitHub) ListRefs(ctx context.Context, owner, repo string, refType RefType) ([]*models.GitHubRef, error) {
func (gh *GitHub) ListRefs(ctx context.Context, owner, repo string, refType RefType) ([]*GitHubRef, error) {
if len(owner) == 0 {
return nil, fmt.Errorf("an owner must be specified")
}

if len(repo) == 0 {
return nil, fmt.Errorf("a repo must be specified")
}

var query struct {
Repository struct {
Refs struct {
Expand Down Expand Up @@ -124,7 +212,7 @@ func (gh *GitHub) ListRefs(ctx context.Context, owner, repo string, refType RefT
"after": (*githubv4.String)(nil),
}

var out []*models.GitHubRef
var out []*GitHubRef
for {
if err := gh.clientV4.Query(ctx, &query, variables); err != nil {
return nil, err
Expand All @@ -134,7 +222,7 @@ func (gh *GitHub) ListRefs(ctx context.Context, owner, repo string, refType RefT
commitDate := n.Target.Commit.CommittedDate
tagDate := n.Target.Tag.Tagger.Date

model := &models.GitHubRef{Name: n.Name, Id: n.Id}
model := &GitHubRef{Name: n.Name, Id: n.Id}
if !commitDate.IsZero() {
model.LastCommitDate = &commitDate
}
Expand All @@ -152,9 +240,9 @@ func (gh *GitHub) ListRefs(ctx context.Context, owner, repo string, refType RefT
return out, nil
}

func (gh *GitHub) DeleteRefs(ctx context.Context, refs ...string) ([]string, error) {
func (gh *GitHub) DeleteRefs(ctx context.Context, refs ...string) error {
if refs == nil || len(refs) == 0 {
return nil, fmt.Errorf("no refs have been specified")
return fmt.Errorf("no refs have been specified")
}

var mutation struct {
Expand All @@ -163,20 +251,39 @@ func (gh *GitHub) DeleteRefs(ctx context.Context, refs ...string) ([]string, err
} `graphql:"deleteRef(input: {refId: $input})"`
}

var out []string
ec := make(chan error, len(refs))
sem := make(chan struct{}, gh.workerCount)

var wg sync.WaitGroup
wg.Add(len(refs))
go func() {
wg.Wait()
close(ec)
close(sem)
}()

for _, ref := range refs {
if err := gh.clientV4.Mutate(ctx, &mutation, githubv4.Input(ref), nil); err != nil {
return out, err
}
out = append(out, mutation.DeleteRef.Typename)
sem <- struct{}{}
go func(r string) {
reqErr := gh.clientV4.Mutate(ctx, &mutation, githubv4.Input(r), nil)
if reqErr != nil {
ec <- fmt.Errorf("unable to delete ref: %v. error: %v", r, reqErr)
}
wg.Done()
<-sem
}(ref)
}

return out, nil
var err error
for e := range ec {
err = errors.Join(err, e)
}
return err
}

func (gh *GitHub) ClosePRs(ctx context.Context, ids ...string) ([]string, error) {
func (gh *GitHub) ClosePRs(ctx context.Context, ids ...string) error {
if ids == nil || len(ids) == 0 {
return nil, fmt.Errorf("no PR ids have been specified")
return fmt.Errorf("no PR ids have been specified")
}

var mutation struct {
Expand All @@ -185,13 +292,33 @@ func (gh *GitHub) ClosePRs(ctx context.Context, ids ...string) ([]string, error)
} `graphql:"closePullRequest(input: {pullRequestId: $input})"`
}

var out []string
ec := make(chan error, len(ids))
sem := make(chan struct{}, gh.workerCount)

var wg sync.WaitGroup
wg.Add(len(ids))
go func() {
wg.Wait()
close(ec)
close(sem)
}()

for _, id := range ids {
if err := gh.clientV4.Mutate(ctx, &mutation, githubv4.Input(id), nil); err != nil {
return out, err
}
out = append(out, mutation.ClosePullRequest.Typename)
sem <- struct{}{}
go func(identifier string) {
reqErr := gh.clientV4.Mutate(ctx, &mutation, githubv4.Input(identifier), nil)
if reqErr != nil {
ec <- fmt.Errorf("unable to close PR: %v. error: %v", identifier, reqErr)
}
wg.Done()
<-sem
}(id)
}

return out, nil
var err error
for e := range ec {
err = errors.Join(err, e)
}

return err
}
Loading

0 comments on commit dd80fc2

Please sign in to comment.