commit c4fe9b1ac825a7760d70aa334ec0c2210a6e908b
parent e5c958cc6d18f09283167faa7a74152ab9062b81
Author: Russ Cox <rsc@golang.org>
Date: Wed, 21 Sep 2022 00:13:14 -0400
internal/minutes: add proposal minutes program
Adding it here is partly to make sure it isn't lost
and partly a demo of the API.
Diffstat:
2 files changed, 464 insertions(+), 0 deletions(-)
diff --git a/internal/minutes/minutes.go b/internal/minutes/minutes.go
@@ -0,0 +1,363 @@
+// Copyright 2022 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.
+
+// Minutes is the program we use to post the proposal review minutes.
+// It is a demonstration of the use of the rsc.io/github API, but it is also not great code,
+// which is why it is buried in an internal directory.
+package main
+
+import (
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "rsc.io/github"
+)
+
+func main() {
+ r, err := NewReporter()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ data, err := io.ReadAll(os.Stdin)
+ if err != nil {
+ log.Fatal(err)
+ }
+ r.Print(r.Update(string(data)))
+}
+
+type Reporter struct {
+ Client *github.Client
+ Proposals *github.Project
+ Items map[int]*github.ProjectItem
+ Labels map[string]*github.Label
+ Backlog *github.Milestone
+}
+
+func NewReporter() (*Reporter, error) {
+ c, err := github.Dial("")
+ if err != nil {
+ return nil, err
+ }
+
+ r := &Reporter{Client: c}
+
+ ps, err := r.Client.Projects("golang", "")
+ if err != nil {
+ return nil, err
+ }
+ for _, p := range ps {
+ if p.Title == "Proposals" {
+ r.Proposals = p
+ break
+ }
+ }
+ if r.Proposals == nil {
+ return nil, fmt.Errorf("cannot find Proposals project")
+ }
+
+ labels, err := r.Client.SearchLabels("golang", "go", "")
+ if err != nil {
+ return nil, err
+ }
+ r.Labels = make(map[string]*github.Label)
+ for _, label := range labels {
+ r.Labels[label.Name] = label
+ }
+
+ milestones, err := r.Client.SearchMilestones("golang", "go", "Backlog")
+ if err != nil {
+ return nil, err
+ }
+ for _, m := range milestones {
+ if m.Title == "Backlog" {
+ r.Backlog = m
+ break
+ }
+ }
+ if r.Backlog == nil {
+ return nil, fmt.Errorf("cannot find Backlog milestone")
+ }
+
+ items, err := r.Client.ProjectItems(r.Proposals)
+ if err != nil {
+ return nil, err
+ }
+ r.Items = make(map[int]*github.ProjectItem)
+ for _, item := range items {
+ if item.Issue == nil {
+ log.Printf("warning: unexpected item with no issue")
+ continue
+ }
+ r.Items[item.Issue.Number] = item
+ }
+
+ return r, nil
+}
+
+type Minutes struct {
+ Who []string
+ Events []*Event
+}
+
+type Event struct {
+ Column string
+ Issue string
+ Title string
+ Actions []string
+}
+
+func (r *Reporter) Update(text string) *Minutes {
+ const prefix = "https://github.com/golang/go/issues/"
+
+ m := new(Minutes)
+ lines := strings.Split(text, "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ line = strings.ReplaceAll(line, "\t", " ")
+ if line == "" {
+ continue
+ }
+ if m.Who == nil {
+ if strings.HasPrefix(line, prefix) {
+ log.Printf("missing attendee list at start of input")
+ break
+ }
+ who := strings.Fields(strings.ReplaceAll(line, ",", " "))
+ for i, w := range who {
+ who[i] = gitWho(w)
+ }
+ m.Who = who
+ continue
+ }
+
+ if !strings.HasPrefix(line, prefix) {
+ log.Printf("unexpected line: %s", line)
+ continue
+ }
+
+ url, actionstr, _ := strings.Cut(line, " ")
+ issuenum := strings.TrimPrefix(url, prefix)
+ url = "https://go.dev/issue/" + issuenum
+ actionstr = strings.TrimSpace(actionstr)
+ if actionstr == "" {
+ log.Printf("line missing actions: %s", line)
+ continue
+ }
+
+ actions := strings.Split(actionstr, ";")
+ col := "Active"
+ reason := ""
+ for i, a := range actions {
+ a = strings.TrimSpace(a)
+ actions[i] = a
+ switch a {
+ case "accept":
+ a = "accepted"
+ case "decline":
+ a = "declined"
+ case "retract":
+ a = "retracted"
+ }
+
+ switch a {
+ case "likely accept":
+ col = "Likely Accept"
+ case "likely decline":
+ col = "Likely Decline"
+ case "accepted":
+ col = "Accepted"
+ case "declined":
+ col = "Declined"
+ case "retracted":
+ col = "Declined"
+ reason = "retracted"
+ case "unhold":
+ col = "Active"
+ reason = "unhold"
+ }
+ if strings.HasPrefix(a, "duplicate") {
+ col = "Declined"
+ reason = "duplicate"
+ }
+ if strings.HasPrefix(a, "infeasible") {
+ col = "Declined"
+ reason = "infeasible"
+ }
+ if strings.HasPrefix(a, "closed") {
+ col = "Declined"
+ }
+ if strings.HasPrefix(a, "hold") || a == "on hold" {
+ col = "Hold"
+ }
+ if r := actionMap[a]; r != "" {
+ actions[i] = r
+ }
+ if strings.HasPrefix(a, "removed") {
+ col = "none"
+ reason = "removed"
+ }
+ }
+
+ id, err := strconv.Atoi(issuenum)
+ if err != nil {
+ log.Fatal(err)
+ }
+ item := r.Items[id]
+ if item == nil {
+ log.Printf("missing from proposal project: #%d", id)
+ continue
+ }
+ issue := item.Issue
+ status := item.FieldByName("Status")
+ if status == nil {
+ log.Printf("item missing status: #%d", id)
+ continue
+ }
+
+ title := strings.TrimSpace(strings.TrimPrefix(issue.Title, "proposal:"))
+ if status.Option.Name != col {
+ msg := updateMsg(status.Option.Name, col, reason)
+ if msg == "" {
+ log.Fatalf("no update message for %s", col)
+ }
+ f := r.Proposals.FieldByName("Status")
+ if col == "none" {
+ if err := r.Client.DeleteProjectItem(r.Proposals, item); err != nil {
+ log.Printf("%s: deleting proposal item: %v", url, err)
+ continue
+ }
+ } else {
+ o := f.OptionByName(col)
+ if o == nil {
+ log.Printf("%s: moving from %s to %s: no such status\n", url, status.Option.Name, col)
+ continue
+ }
+ if err := r.Client.SetProjectItemFieldOption(r.Proposals, item, f, o); err != nil {
+ log.Printf("%s: moving from %s to %s: %v\n", url, status.Option.Name, col, err)
+ }
+ }
+ if err := r.Client.AddIssueComment(issue, msg); err != nil {
+ log.Printf("%s: posting comment: %v", url, err)
+ }
+ }
+
+ needLabel := func(name string) {
+ if issue.LabelByName(name) == nil {
+ lab := r.Labels[name]
+ if lab == nil {
+ log.Fatalf("%s: cannot find label %s", url, name)
+ }
+ if err := r.Client.AddIssueLabels(issue, lab); err != nil {
+ log.Printf("%s: adding %s: %v", url, name, err)
+ }
+ }
+ }
+
+ dropLabel := func(name string) {
+ if lab := issue.LabelByName(name); lab != nil {
+ if err := r.Client.RemoveIssueLabels(issue, lab); err != nil {
+ log.Printf("%s: removing %s: %v", url, name, err)
+ }
+ }
+ }
+
+ forceClose := func() {
+ if !issue.Closed {
+ if err := r.Client.CloseIssue(issue); err != nil {
+ log.Printf("%s: closing issue: %v", url, err)
+ }
+ }
+ }
+
+ switch col {
+ case "Accepted":
+ if strings.HasPrefix(issue.Title, "proposal:") {
+ if err := r.Client.RetitleIssue(issue, title); err != nil {
+ log.Printf("%s: retitling: %v", url, err)
+ }
+ }
+ needLabel("Proposal-Accepted")
+ if issue.Milestone == nil || issue.Milestone.Title == "Proposal" {
+ if err := r.Client.RemilestoneIssue(issue, r.Backlog); err != nil {
+ log.Printf("%s: moving out of Proposal milestone: %v", url, err)
+ }
+ }
+ case "Declined":
+ dropLabel("Proposal-FinalCommentPeriod")
+ forceClose()
+ case "Likely Accept", "Likely Decline":
+ needLabel("Proposal-FinalCommentPeriod")
+ case "Hold":
+ needLabel("Proposal-Hold")
+ }
+ m.Events = append(m.Events, &Event{Column: col, Issue: issuenum, Title: title, Actions: actions})
+ }
+
+ sort.Slice(m.Events, func(i, j int) bool {
+ return m.Events[i].Title < m.Events[j].Title
+ })
+ return m
+}
+
+func (r *Reporter) Print(m *Minutes) {
+ fmt.Printf("**%s / ", time.Now().Format("2006-01-02"))
+ for i, who := range m.Who {
+ if i > 0 {
+ fmt.Printf(", ")
+ }
+ fmt.Printf("%s", who)
+ }
+ fmt.Printf("**\n\n")
+
+ columns := []string{
+ "Accepted",
+ "Declined",
+ "Likely Accept",
+ "Likely Decline",
+ "Active",
+ "Hold",
+ "Other",
+ }
+
+ for _, col := range columns {
+ n := 0
+ for i, e := range m.Events {
+ if e == nil || e.Column != col && col != "Other" {
+ continue
+ }
+ if n == 0 {
+ fmt.Printf("**%s**\n\n", col)
+ }
+ n++
+ fmt.Printf("- **%s** [#%s](https://go.dev/issue/%s)\n", markdownEscape(strings.TrimSpace(e.Title)), e.Issue, e.Issue)
+ for _, a := range e.Actions {
+ fmt.Printf(" - %s\n", a)
+ }
+ m.Events[i] = nil
+ }
+ if n == 0 && col != "Hold" && col != "Other" {
+ fmt.Printf("**%s**\n\n", col)
+ fmt.Printf("- none\n")
+ }
+ fmt.Printf("\n")
+ }
+}
+
+var markdownEscaper = strings.NewReplacer(
+ "_", `\_`,
+ "*", `\*`,
+ "`", "\\`",
+ "[", `\[`,
+)
+
+func markdownEscape(s string) string {
+ return markdownEscaper.Replace(s)
+}
diff --git a/internal/minutes/tables.go b/internal/minutes/tables.go
@@ -0,0 +1,101 @@
+// Copyright 2022 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.
+
+package main
+
+import (
+ "fmt"
+ "os"
+)
+
+var whoMap = map[string]string{
+ "andybons": "andybons",
+ "bradfitz": "bradfitz",
+ "gri": "griesemer",
+ "iant": "ianlancetaylor",
+ "r": "robpike",
+ "rsc": "rsc",
+ "sfrancia": "spf13",
+ "austin": "aclements",
+ "julieqiu": "julieqiu",
+}
+
+func gitWho(who string) string {
+ if whoMap[who] != "" {
+ return "@" + whoMap[who]
+ }
+ fmt.Fprintf(os.Stderr, "warning: unknown attendee %s; assuming GitHub @%s\n", who, who)
+ return "@" + who
+}
+
+var actionMap = map[string]string{
+ "accepted": "no change in consensus; **accepted** 🎉",
+ "declined": "no change in consensus; **declined**",
+ "retracted": "proposal retracted by author; **declined**",
+ "hold": "put on hold",
+ "on hold": "put on hold",
+ "unhold": "taken off hold",
+ "likely accept": "**likely accept**; last call for comments ⏳",
+ "likely decline": "**likely decline**; last call for comments ⏳",
+ "discuss": "discussion ongoing",
+ "removed": "removed from proposal process",
+ "comment": "commented",
+ "infeasible": "declined as infeasible",
+}
+
+func updateMsg(old, new, reason string) string {
+ if msg := updateMsgs[reason]; msg != "" {
+ return msg
+ }
+ return updateMsgs[new]
+}
+
+var updateMsgs = map[string]string{
+ "duplicate": `
+This proposal is a duplicate of a previously discussed proposal, as noted above,
+and there is no significant new information to justify reopening the discussion.
+The issue has therefore been **[declined as a duplicate](https://go.dev/s/proposal-status#declined-as-duplicate)**.
+— rsc for the proposal review group
+`,
+ "retracted": `
+This proposal has been **[declined as retracted](https://go.dev/s/proposal-status#declined-as-retracted)**.
+— rsc for the proposal review group
+`,
+ "infeasible": `
+This proposal has been **[declined as infeasible](https://go.dev/s/proposal-status#declined-as-infeasible)**.
+— rsc for the proposal review group
+`,
+ "Active": `
+This proposal has been added to the [active column](https://go.dev/s/proposal-status#active) of the proposals project
+and will now be reviewed at the weekly proposal review meetings.
+— rsc for the proposal review group
+`,
+ "Likely Accept": `
+Based on the discussion above, this proposal seems like a **[likely accept](https://go.dev/s/proposal-status#likely-accept)**.
+— rsc for the proposal review group
+`,
+ "Likely Decline": `
+Based on the discussion above, this proposal seems like a **[likely decline](https://go.dev/s/proposal-status#likely-decline)**.
+— rsc for the proposal review group
+`,
+ "Accepted": `
+No change in consensus, so **[accepted](https://go.dev/s/proposal-status#accepted)**. 🎉
+This issue now tracks the work of implementing the proposal.
+— rsc for the proposal review group
+`,
+ "Declined": `
+No change in consensus, so **[declined](https://go.dev/s/proposal-status#declined)**.
+— rsc for the proposal review group
+`,
+ "Hold": `
+**[Placed on hold](https://go.dev/s/proposal-status#hold)**.
+— rsc for the proposal review group
+`,
+ "removed": `
+**Removed from the [proposal process](https://go.dev/s/proposal)**.
+This was determined not to be a “significant change to the language, libraries, or tools”
+or otherwise of significant importance or interest to the broader Go community.
+— rsc for the proposal review group
+`,
+}