...

Source file src/edge-infra.dev/hack/tools/hiss/main.go

Documentation: edge-infra.dev/hack/tools/hiss

     1  // Command Hiss is a gitHub ISSue creator utility and how you feel when you are
     2  // creating issues.
     3  //
     4  // Hiss parses a markdown file containing multiple GitHub issue bodies and
     5  // creates individual issues.
     6  //
     7  // # File Format
     8  //
     9  // - `---` separates issues
    10  // - `#` indicates issue title. There can only be a single level 1 header per issue.
    11  // - A line starting with `labels:` can be provided to add labels to the issue.
    12  // - A line starting with `parent: title of another issue` can be used to reference issues defined earlier in the file.
    13  //   - The standard /parent can still be used to reference existing issues.
    14  //
    15  // # Usage
    16  //
    17  // Create issues from `issues.md` and add the `team/waterboys` + `area/oci` labels to all created issues.
    18  //
    19  //	export GITHUB_TOKEN=${YOUR_ACTUAL_GITHUB_TOKEN}
    20  //	bazel run hack/tools/hiss --action_env=GITHUB_TOKEN -- -f issues.md --labels team/waterboys,area/oci
    21  package main
    22  
    23  import (
    24  	"bufio"
    25  	"bytes"
    26  	"context"
    27  	"flag"
    28  	"fmt"
    29  	"os"
    30  	"strings"
    31  
    32  	"github.com/google/go-github/v47/github"
    33  	"github.com/peterbourgon/ff/v3"
    34  	"github.com/peterbourgon/ff/v3/ffcli"
    35  	"golang.org/x/oauth2"
    36  
    37  	"edge-infra.dev/pkg/lib/build/bazel"
    38  	"edge-infra.dev/pkg/lib/cli/commands"
    39  )
    40  
    41  var (
    42  	fs = flag.NewFlagSet("hiss", flag.ContinueOnError)
    43  
    44  	token    = fs.String("github-token", "", "github personal access token")
    45  	repo     = fs.String("repo", "edge-roadmap", "target repo")
    46  	owner    = fs.String("owner", "ncrvoyix-swt-retail", "target repo owner")
    47  	moLabels = fs.String("labels", "", "comma separated list of labels to apply to all issues")
    48  	file     = fs.String("f", "", "path to markdown file containing issues to create")
    49  )
    50  
    51  const (
    52  	delimiter = "---"
    53  	title     = "#"
    54  	labels    = "labels:"
    55  	parent    = "parent:"
    56  )
    57  
    58  func main() {
    59  	cmd := &ffcli.Command{
    60  		Name:       "hiss",
    61  		ShortUsage: "hiss -f issues.md <args>",
    62  		ShortHelp:  "creates github issues from a markdown file",
    63  		FlagSet:    fs,
    64  		Options: []ff.Option{
    65  			ff.WithEnvVarNoPrefix(),
    66  		},
    67  		Subcommands: []*ffcli.Command{
    68  			commands.Version(),
    69  		},
    70  		Exec: hiss,
    71  	}
    72  
    73  	if err := os.Chdir(bazel.ResolveWdOrDie()); err != nil {
    74  		fmt.Fprintln(os.Stderr, "failed to set cwd: ", err)
    75  		os.Exit(1)
    76  	}
    77  
    78  	if err := cmd.ParseAndRun(context.Background(), os.Args[1:]); err != nil {
    79  		fmt.Fprintln(os.Stderr, "error: ", err)
    80  		os.Exit(1)
    81  	}
    82  }
    83  
    84  func hiss(_ context.Context, _ []string) error {
    85  	ctx := context.Background()
    86  	httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(
    87  		&oauth2.Token{AccessToken: *token},
    88  	))
    89  	client := github.NewClient(httpClient)
    90  
    91  	issues, err := parseIssues(*file)
    92  	if err != nil {
    93  		return err
    94  	}
    95  
    96  	fmt.Println("going to create:")
    97  	for _, issue := range issues {
    98  		fmt.Printf("- %s (%s)\n", issue.title, issue.labels)
    99  	}
   100  
   101  	parents := map[string]int{}
   102  	for i, issue := range issues {
   103  		// compute labels
   104  		l := issue.labels
   105  		if *moLabels != "" {
   106  			l = append(issue.labels, strings.Split(*moLabels, ",")...)
   107  		}
   108  		// replace parents
   109  		for _, parent := range issue.parents {
   110  			if parents[parent] == 0 {
   111  				return fmt.Errorf("parent referenced that doesnt exist earlier in the file: '%s'", parent)
   112  			}
   113  			issue.body = issue.body + fmt.Sprintf("\n/parent #%d", parents[parent])
   114  		}
   115  
   116  		created, _, err := client.Issues.Create(ctx, *owner, *repo, &github.IssueRequest{
   117  			Title:  &issues[i].title,
   118  			Body:   &issues[i].body,
   119  			Labels: &l,
   120  		})
   121  		if err != nil {
   122  			return fmt.Errorf("failed to create %s: %w", issue.title, err)
   123  		}
   124  
   125  		// store issue number in case this issue is a parent later on
   126  		parents[issue.title] = *created.Number
   127  		fmt.Printf("created #%d: %s\n", *created.Number, issue.title)
   128  	}
   129  
   130  	return nil
   131  }
   132  
   133  func parseIssues(path string) ([]issue, error) {
   134  	data, err := os.ReadFile(path)
   135  	if err != nil {
   136  		return nil, err
   137  	}
   138  
   139  	issues := []issue{}
   140  
   141  	scanner := bufio.NewScanner(bytes.NewReader(data))
   142  	i := issue{labels: []string{}}
   143  	body := []string{}
   144  	for scanner.Scan() {
   145  		text := scanner.Text()
   146  		switch {
   147  		case strings.HasPrefix(text, title):
   148  			if i.title != "" {
   149  				return nil, fmt.Errorf("encountered title ('#') twice in one issue body")
   150  			}
   151  			i.title = strings.TrimSpace(strings.TrimPrefix(text, title))
   152  		case strings.HasPrefix(text, labels):
   153  			if len(i.labels) != 0 {
   154  				return nil, fmt.Errorf("encountered key '%s' twice in one issue body", labels)
   155  			}
   156  			i.labels = strings.Split(strings.TrimSpace(strings.TrimPrefix(text, labels)), " ")
   157  		case strings.HasPrefix(text, parent):
   158  			i.parents = append(i.parents,
   159  				strings.TrimSpace(strings.TrimPrefix(text, parent)),
   160  			)
   161  		case text == delimiter:
   162  			i.body = strings.TrimSpace(strings.Join(body, "\n"))
   163  			issues = append(issues, i)
   164  			// reset state for next issue
   165  			i = issue{}
   166  			body = []string{}
   167  		default:
   168  			body = append(body, text)
   169  		}
   170  	}
   171  
   172  	return issues, nil
   173  }
   174  
   175  type issue struct {
   176  	title   string
   177  	body    string
   178  	labels  []string
   179  	parents []string
   180  }
   181  

View as plain text