Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Label renaming and import statements #68

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,56 @@ The default file path is `.github/labels.yml`, but you can specify any file path

To create manifest of the current labels easily, using [label-exporter](https://github.com/micnncim/label-exporter) is recommended.

### Renaming labels

If you want to rename a label, you can set an `alias` in the manifest.
For example, if you want to rename the label `bug` to `Type: bug`, you
would use a manifest like this:

```yaml
- name: Type: bug
alias: bug
description: Something isn't working
color: d73a4a
```

Renaming labels makes it easier to adopt a new taxonomy if you have
issues and pull requests using the old label names. Since you're
renaming labels rather than deleting and creating new ones, existing
pull requests and issues will keep their labels, but will adopt the
new name.

You can also set multiple aliases, which can be useful when reusing a
configuration across multiple repositories, each of which may have a different
existing label. For example:

```yaml
- name: Type: bug
aliases:
- bug
- defect
- "Seriously, what was I thinking?"
description: Something isn't working
color: d73a4a
```

### Import statements

To reuse some common configurations across repositories, add an item with just
one field called `import`, which contains a path to another yaml file. The
contents of the imported file will be treated as if they appeared in the main
file. For example:

```yaml
# Common label definitions for all projects, such as "Type: bug".
- import: common.yaml

# Labels specific to this project:
- name: Platform: iOS
description: Issues specific to iOS
color: d7ea4a
```

### Create Workflow

An example workflow is here.
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ inputs:
description: "Remove unmanaged labels from repository"
required: false
default: true
dry_run:
description: "Print what would be done, but do nothing"
required: false
default: false
runs:
using: "docker"
image: "Dockerfile"
Expand Down
13 changes: 11 additions & 2 deletions cmd/action-label-syncer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ func run(ctx context.Context) error {
return fmt.Errorf("unable to parse prune: %w", err)
}

dryRun := false
dryRunEnv := os.Getenv("INPUT_DRY_RUN")
if dryRunEnv != "" {
dryRun, err = strconv.ParseBool(os.Getenv("INPUT_DRY_RUN"))
if err != nil {
return fmt.Errorf("unable to parse dry-run: %w", err)
}
}

token := os.Getenv("INPUT_TOKEN")
if len(token) == 0 {
token = os.Getenv("GITHUB_TOKEN")
Expand All @@ -67,8 +76,8 @@ func run(ctx context.Context) error {
}
owner, repo := s[0], s[1]

if err := client.SyncLabels(ctx, owner, repo, labels, prune); err != nil {
err = multierr.Append(err, fmt.Errorf("unable to sync labels: %w", err))
if err := client.SyncLabels(ctx, owner, repo, labels, prune, dryRun); err != nil {
return fmt.Errorf("unable to sync labels: %w", err)
}
}

Expand Down
127 changes: 95 additions & 32 deletions pkg/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ import (
"context"
"fmt"
"io/ioutil"
"path/filepath"
"strings"

"github.com/google/go-github/github"
"golang.org/x/oauth2"
"golang.org/x/sync/errgroup"
"gopkg.in/yaml.v2"
)

Expand All @@ -31,7 +32,11 @@ type Client struct {
}

type Label struct {
// If "import" is present, all other fields are ignored.
Import string `yaml:"import"`
Name string `yaml:"name"`
Alias string `yaml:"alias"`
Aliases []string `yaml:"aliases"`
Description string `yaml:"description"`
Color string `yaml:"color"`
}
Expand All @@ -41,9 +46,39 @@ func FromManifestToLabels(path string) ([]Label, error) {
if err != nil {
return nil, err
}

var labels []Label
err = yaml.Unmarshal(buf, &labels)
return labels, err
if err != nil {
return nil, err
}

var flatLabels []Label
for _, l := range labels {
if l.Import == "" {
// Data checks and normalization.
if len(l.Description) > 100 {
return nil, fmt.Errorf("Description of \"%s\" exceeds 100 characters", l.Name)
}
if strings.Contains(l.Name, "?") {
return nil, fmt.Errorf("Label name cannot contain question marks: \"%s\"", l.Name)
}
if l.Alias != "" {
l.Aliases = append(l.Aliases, l.Alias)
}
flatLabels = append(flatLabels, l)
} else {
// Handle imports of labels from another file
importPath := filepath.Join(filepath.Dir(path), l.Import)
importedLabels, err := FromManifestToLabels(importPath)
if err != nil {
return nil, err
}
flatLabels = append(flatLabels, importedLabels...)
}
}

return flatLabels, err
}

func NewClient(token string) *Client {
Expand All @@ -57,10 +92,18 @@ func NewClient(token string) *Client {
}
}

func (c *Client) SyncLabels(ctx context.Context, owner, repo string, labels []Label, prune bool) error {
func (c *Client) SyncLabels(ctx context.Context, owner, repo string, labels []Label, prune bool, dryRun bool) error {
if dryRun {
fmt.Printf("Dry run! No actual changes will be made.\n")
}

labelMap := make(map[string]Label)
aliasMap := make(map[string]Label)
for _, l := range labels {
labelMap[l.Name] = l
for _, alias := range l.Aliases {
aliasMap[alias] = l
}
}

currentLabels, err := c.getLabels(ctx, owner, repo)
Expand All @@ -72,53 +115,63 @@ func (c *Client) SyncLabels(ctx context.Context, owner, repo string, labels []La
currentLabelMap[l.Name] = l
}

eg := errgroup.Group{}

// Delete labels.
if prune {
for _, currentLabel := range currentLabels {
currentLabel := currentLabel
eg.Go(func() error {
_, ok := labelMap[currentLabel.Name]
if ok {
return nil
_, name_ok := labelMap[currentLabel.Name]
_, alias_ok := aliasMap[currentLabel.Name]
if !alias_ok && !name_ok {
err := c.deleteLabel(ctx, owner, repo, currentLabel.Name, dryRun)
if err != nil {
return err
}
return c.deleteLabel(ctx, owner, repo, currentLabel.Name)
})
}

if err := eg.Wait(); err != nil {
return err
}
}
}

// Create and/or update labels.
for _, l := range labels {
l := l
eg.Go(func() error {
currentLabel, ok := currentLabelMap[l.Name]
if !ok {
return c.createLabel(ctx, owner, repo, l)
currentLabel, ok := currentLabelMap[l.Name]
if !ok {
for _, alias := range l.Aliases {
currentLabel, ok = currentLabelMap[alias]
if ok {
break
}
}
if currentLabel.Description != l.Description || currentLabel.Color != l.Color {
return c.updateLabel(ctx, owner, repo, l)
}

if !ok {
err := c.createLabel(ctx, owner, repo, l, dryRun)
if err != nil {
return err
}
fmt.Printf("label: %+v not changed on %s/%s\n", l, owner, repo)
return nil
})
} else if currentLabel.Description != l.Description || currentLabel.Color != l.Color || currentLabel.Name != l.Name {
err := c.updateLabel(ctx, owner, repo, currentLabel.Name, l, dryRun)
if err != nil {
return err
}
} else {
//fmt.Printf("Not changed: \"%s\" on %s/%s\n", l.Name, owner, repo)
}
}

return eg.Wait()
return nil
}

func (c *Client) createLabel(ctx context.Context, owner, repo string, label Label) error {
func (c *Client) createLabel(ctx context.Context, owner, repo string, label Label, dryRun bool) error {
l := &github.Label{
Name: &label.Name,
Description: &label.Description,
Color: &label.Color,
}
fmt.Printf("Created: \"%s\" on %s/%s\n", label.Name, owner, repo)
if dryRun {
return nil
}
_, _, err := c.githubClient.Issues.CreateLabel(ctx, owner, repo, l)
fmt.Printf("label: %+v created on: %s/%s\n", label, owner, repo)
return err
}

Expand Down Expand Up @@ -147,19 +200,29 @@ func (c *Client) getLabels(ctx context.Context, owner, repo string) ([]Label, er
return labels, nil
}

func (c *Client) updateLabel(ctx context.Context, owner, repo string, label Label) error {
func (c *Client) updateLabel(ctx context.Context, owner, repo, labelName string, label Label, dryRun bool) error {
l := &github.Label{
Name: &label.Name,
Description: &label.Description,
Color: &label.Color,
}
_, _, err := c.githubClient.Issues.EditLabel(ctx, owner, repo, label.Name, l)
fmt.Printf("label %+v updated on: %s/%s\n", label, owner, repo)
if labelName != label.Name {
fmt.Printf("Renamed: \"%s\" => \"%s\" on %s/%s\n", labelName, label.Name, owner, repo)
} else {
fmt.Printf("Updated: \"%s\" on %s/%s\n", label.Name, owner, repo)
}
if dryRun {
return nil
}
_, _, err := c.githubClient.Issues.EditLabel(ctx, owner, repo, labelName, l)
return err
}

func (c *Client) deleteLabel(ctx context.Context, owner, repo, name string) error {
func (c *Client) deleteLabel(ctx context.Context, owner, repo, name string, dryRun bool) error {
fmt.Printf("Deleted: \"%s\" on %s/%s\n", name, owner, repo)
if dryRun {
return nil
}
_, err := c.githubClient.Issues.DeleteLabel(ctx, owner, repo, name)
fmt.Printf("label: %s deleted from: %s/%s\n", name, owner, repo)
return err
}