Skip to content

Commit

Permalink
release(1.0.4):
Browse files Browse the repository at this point in the history
feat: added API batching support
feat: added GitHub Enterprise support
feat: added support to delete refs in the format of a branch or tag name
feat: added full pagination support
feat: add simple concurrency deletion/closing calls; errors will be aggregated and only displayed at the end
feat: added test suite for the MiTM API that interacts with GitHub
  • Loading branch information
pcanilho committed Aug 31, 2023
1 parent bf24f99 commit 1370760
Show file tree
Hide file tree
Showing 13 changed files with 526 additions and 54 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
35 changes: 22 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,31 +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)
[![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` or `PR` format) by providing rules, such as `stale` status based on last commit date for a given branch, PR activity and others.
(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.
or open an Issue on GitHub!

📝 TODOs (for lack of time on my side):
📝 TODOs (for lack of time...):
* API:
* Automatic rate limiting handling
* Calls batching
* Support GitHub Enterprise
* Branches:
* Detect if branch has already been merged
* 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 @@ -34,7 +43,7 @@ or open an Issue on GitHub.
$ gh tidy --help
```

\* This can be a `PAT`, a GitHub App installation `access_token` or any other format that allows `api.github.com` access.
\* 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
110 changes: 95 additions & 15 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,95 @@ import (
"errors"
"fmt"
"github.com/google/go-github/github"
"github.com/pcanilho/gh-tidy/helpers"
"github.com/shurcooL/githubv4"
"golang.org/x/oauth2"
"os"
"net/http"
"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 NewSession() (*GitHub, error) {
token := os.Getenv("GITHUB_TOKEN")
if len(strings.TrimSpace(token)) == 0 {
return nil, fmt.Errorf("a GITHUB_TOKEN environment variable needs to be set")
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(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
}

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)
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) ([]*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 Down Expand Up @@ -103,6 +165,14 @@ const (
)

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 @@ -176,20 +246,25 @@ func (gh *GitHub) DeleteRefs(ctx context.Context, refs ...string) error {
}

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 {
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)
}

Expand All @@ -212,26 +287,31 @@ func (gh *GitHub) ClosePRs(ctx context.Context, ids ...string) error {
}

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 {
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)
}

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

return err
Expand Down
Loading

0 comments on commit 1370760

Please sign in to comment.