diff --git a/internal/devtools/cmd/labelhist/main.go b/internal/devtools/cmd/labelhist/main.go new file mode 100644 index 0000000..bbc5b57 --- /dev/null +++ b/internal/devtools/cmd/labelhist/main.go @@ -0,0 +1,101 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +/* +Labelhist displays the events of a GitHub issue that affect its labels. + +Usage: + + labelhist issues... + +Each argument can be a single issue number or a range of numbers "from-to". +*/ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "log/slog" + "os" + "strconv" + "strings" + + "golang.org/x/oscar/internal/gcp/firestore" + "golang.org/x/oscar/internal/github" +) + +var project = flag.String("project", "golang/go", "GitHub project") + +func usage() { + fmt.Fprintf(os.Stderr, "usage: labelhist issues...\n") + flag.PrintDefaults() + os.Exit(2) +} + +func main() { + log.SetFlags(0) + log.SetPrefix("labelhist: ") + flag.Usage = usage + flag.Parse() + if err := run(context.Background()); err != nil { + log.Fatal(err) + } +} + +func run(ctx context.Context) error { + var ranges []Range + for _, arg := range flag.Args() { + r, err := parseIssueArg(arg) + if err != nil { + return err + } + ranges = append(ranges, r) + } + lg := slog.New(slog.NewTextHandler(os.Stderr, nil)) + db, err := firestore.NewDB(ctx, lg, "oscar-go-1", "prod") + if err != nil { + return err + } + + for _, r := range ranges { + for ev := range github.Events(db, *project, r.min, r.max) { + switch ev.API { + case "/issues": + fmt.Printf("%d:\n", ev.Issue) + case "/issues/events": + ie := ev.Typed.(*github.IssueEvent) + if ie.Event == "labeled" || ie.Event == "unlabeled" { + c := '+' + if ie.Event == "unlabeled" { + c = '-' + } + fmt.Printf(" %s %-10s %c%s\n", ie.CreatedAt, ie.Actor.Login, c, ie.Label.Name) + } + } + } + } + return nil +} + +type Range struct { + min, max int64 +} + +func parseIssueArg(s string) (Range, error) { + sfrom, sto, found := strings.Cut(s, "-") + from, err := strconv.ParseInt(sfrom, 10, 64) + if err != nil { + return Range{}, err + } + if !found { + return Range{from, from}, nil + } + to, err := strconv.ParseInt(sto, 10, 64) + if err != nil { + return Range{}, err + } + return Range{from, to}, nil +} diff --git a/internal/github/data.go b/internal/github/data.go index 0748a3b..38893a5 100644 --- a/internal/github/data.go +++ b/internal/github/data.go @@ -62,7 +62,7 @@ func LookupIssue(db storage.DB, project string, issue int64) (*Issue, error) { // only consulting the database (not actual GitHub). func LookupIssues(db storage.DB, project string, issueMin, issueMax int64) iter.Seq[*Issue] { return func(yield func(*Issue) bool) { - for e := range events(db, project, issueMin, issueMax) { + for e := range Events(db, project, issueMin, issueMax) { if e.API == "/issues" { if !yield(e.Typed.(*Issue)) { break @@ -107,6 +107,11 @@ func CleanBody(body string) string { return body } +// Events calls [Events] with the client's db. +func (c *Client) Events(project string, issueMin, issueMax int64) iter.Seq[*Event] { + return Events(c.db, project, issueMin, issueMax) +} + // Events returns an iterator over issue events for the given project, // limited to issues in the range issueMin ≤ issue ≤ issueMax. // If issueMax < 0, there is no upper limit. @@ -114,11 +119,7 @@ func CleanBody(body string) string { // so "/issues" events come first, then "/issues/comments", then "/issues/events". // Within a specific API, the events are ordered by increasing ID, // which corresponds to increasing event time on GitHub. -func (c *Client) Events(project string, issueMin, issueMax int64) iter.Seq[*Event] { - return events(c.db, project, issueMin, issueMax) -} - -func events(db storage.DB, project string, issueMin, issueMax int64) iter.Seq[*Event] { +func Events(db storage.DB, project string, issueMin, issueMax int64) iter.Seq[*Event] { return func(yield func(*Event) bool) { start := o(project, issueMin) if issueMax < 0 { @@ -206,6 +207,7 @@ type IssueEvent struct { URL string `json:"url"` Actor User `json:"actor"` Event string `json:"event"` + Label Label `json:"label"` // for "labeled" and "unlabeled" events Labels []Label `json:"labels"` LockReason string `json:"lock_reason"` CreatedAt string `json:"created_at"`