├── internal ├── minutes2 │ ├── go.work │ ├── go.mod │ ├── tables.go │ ├── gdoc.go │ ├── minutes.go │ └── go.sum ├── minutes3 │ ├── go.work │ ├── go.mod │ ├── go.work.sum │ ├── tables.go │ ├── gdoc.go │ ├── go.sum │ └── minutes.go ├── minutes │ ├── go.mod │ ├── tables.go │ └── minutes.go └── graphql │ ├── netrc.go │ └── graphql.go ├── README.md ├── schema ├── doc.go ├── schema.tmpl └── generate.go ├── go.mod ├── github.go ├── netrc.go ├── LICENSE ├── go.sum ├── client.go ├── issuedb ├── todo.go └── main.go ├── project.go ├── issue ├── edit.go └── acme.go └── issue.go /internal/minutes2/go.work: -------------------------------------------------------------------------------- 1 | go 1.23 2 | 3 | use ( 4 | . 5 | ../.. 6 | ) 7 | -------------------------------------------------------------------------------- /internal/minutes3/go.work: -------------------------------------------------------------------------------- 1 | go 1.23 2 | 3 | use ( 4 | . 5 | ../.. 6 | ) 7 | -------------------------------------------------------------------------------- /internal/minutes/go.mod: -------------------------------------------------------------------------------- 1 | module rsc.io/github/internal/minutes 2 | 3 | go 1.22 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This repo holds Go code for interacting with GitHub. 2 | See in particular the issue command, https://godoc.org/rsc.io/github/issue 3 | -------------------------------------------------------------------------------- /schema/doc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package schema is Go data structures corresponding to the GitHub GraphQL schema. 6 | // 7 | // It is generated by generate.go. 8 | package schema 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module rsc.io/github 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | 9fans.net/go v0.0.7 7 | github.com/google/go-github/v62 v62.0.0 8 | golang.org/x/oauth2 v0.21.0 9 | rsc.io/dbstore v0.1.1 10 | rsc.io/sqlite v1.0.0 11 | rsc.io/todo v0.0.3 12 | ) 13 | 14 | require ( 15 | github.com/google/go-querystring v1.1.0 // indirect 16 | rsc.io/tmplfunc v0.0.3 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /github.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package github provides idiomatic Go APIs for accessing basic GitHub issue operations. 6 | // 7 | // The entire GitHub API can be accessed by using the [Client] with GraphQL schema from 8 | // [rsc.io/github/schema]. 9 | package github 10 | -------------------------------------------------------------------------------- /internal/graphql/netrc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package graphql 6 | 7 | import ( 8 | "fmt" 9 | "io/ioutil" 10 | "os" 11 | "path/filepath" 12 | "runtime" 13 | "strings" 14 | ) 15 | 16 | func netrcAuth(host string) (string, string, error) { 17 | netrc := ".netrc" 18 | if runtime.GOOS == "windows" { 19 | netrc = "_netrc" 20 | } 21 | 22 | homeDir, err := os.UserHomeDir() 23 | if err != nil { 24 | return "", "", err 25 | } 26 | data, _ := ioutil.ReadFile(filepath.Join(homeDir, netrc)) 27 | for _, line := range strings.Split(string(data), "\n") { 28 | if i := strings.Index(line, "#"); i >= 0 { 29 | line = line[:i] 30 | } 31 | f := strings.Fields(line) 32 | if len(f) >= 6 && f[0] == "machine" && f[1] == host && f[2] == "login" && f[4] == "password" { 33 | return f[3], f[5], nil 34 | } 35 | } 36 | return "", "", fmt.Errorf("cannot find netrc entry for %s", host) 37 | } 38 | -------------------------------------------------------------------------------- /netrc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package github 6 | 7 | import ( 8 | "fmt" 9 | "io/ioutil" 10 | "os" 11 | "path/filepath" 12 | "runtime" 13 | "strings" 14 | ) 15 | 16 | func netrcAuth(host, user string) (string, string, error) { 17 | netrc := ".netrc" 18 | if runtime.GOOS == "windows" { 19 | netrc = "_netrc" 20 | } 21 | 22 | homeDir, err := os.UserHomeDir() 23 | if err != nil { 24 | return "", "", err 25 | } 26 | data, _ := ioutil.ReadFile(filepath.Join(homeDir, netrc)) 27 | for _, line := range strings.Split(string(data), "\n") { 28 | if i := strings.Index(line, "#"); i >= 0 { 29 | line = line[:i] 30 | } 31 | f := strings.Fields(line) 32 | if len(f) >= 6 && f[0] == "machine" && f[1] == host && f[2] == "login" && f[4] == "password" && (user == "" || f[3] == user) { 33 | return f[3], f[5], nil 34 | } 35 | } 36 | return "", "", fmt.Errorf("cannot find netrc entry for %s", host) 37 | } 38 | -------------------------------------------------------------------------------- /internal/minutes3/go.mod: -------------------------------------------------------------------------------- 1 | module rsc.io/github/internal/minutes3 2 | 3 | go 1.23 4 | 5 | require ( 6 | golang.org/x/oauth2 v0.21.0 7 | google.golang.org/api v0.189.0 8 | ) 9 | 10 | require ( 11 | cloud.google.com/go/auth v0.7.2 // indirect 12 | cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect 13 | cloud.google.com/go/compute/metadata v0.5.0 // indirect 14 | github.com/felixge/httpsnoop v1.0.4 // indirect 15 | github.com/go-logr/logr v1.4.2 // indirect 16 | github.com/go-logr/stdr v1.2.2 // indirect 17 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 18 | github.com/golang/protobuf v1.5.4 // indirect 19 | github.com/google/s2a-go v0.1.7 // indirect 20 | github.com/google/uuid v1.6.0 // indirect 21 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect 22 | github.com/googleapis/gax-go/v2 v2.12.5 // indirect 23 | go.opencensus.io v0.24.0 // indirect 24 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 25 | go.opentelemetry.io/otel v1.24.0 // indirect 26 | go.opentelemetry.io/otel/metric v1.24.0 // indirect 27 | go.opentelemetry.io/otel/trace v1.24.0 // indirect 28 | golang.org/x/crypto v0.25.0 // indirect 29 | golang.org/x/net v0.27.0 // indirect 30 | golang.org/x/sys v0.22.0 // indirect 31 | golang.org/x/text v0.16.0 // indirect 32 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade // indirect 33 | google.golang.org/grpc v1.64.1 // indirect 34 | google.golang.org/protobuf v1.34.2 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /internal/minutes2/go.mod: -------------------------------------------------------------------------------- 1 | module rsc.io/github/internal/minutes2 2 | 3 | go 1.23 4 | 5 | require ( 6 | golang.org/x/oauth2 v0.21.0 7 | google.golang.org/api v0.185.0 8 | rsc.io/github v0.4.0 9 | ) 10 | 11 | require ( 12 | cloud.google.com/go/auth v0.5.1 // indirect 13 | cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect 14 | cloud.google.com/go/compute/metadata v0.3.0 // indirect 15 | github.com/felixge/httpsnoop v1.0.4 // indirect 16 | github.com/go-logr/logr v1.4.2 // indirect 17 | github.com/go-logr/stdr v1.2.2 // indirect 18 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 19 | github.com/golang/protobuf v1.5.4 // indirect 20 | github.com/google/s2a-go v0.1.7 // indirect 21 | github.com/google/uuid v1.6.0 // indirect 22 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect 23 | github.com/googleapis/gax-go/v2 v2.12.5 // indirect 24 | go.opencensus.io v0.24.0 // indirect 25 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 // indirect 26 | go.opentelemetry.io/otel v1.27.0 // indirect 27 | go.opentelemetry.io/otel/metric v1.27.0 // indirect 28 | go.opentelemetry.io/otel/trace v1.27.0 // indirect 29 | golang.org/x/crypto v0.24.0 // indirect 30 | golang.org/x/net v0.26.0 // indirect 31 | golang.org/x/sys v0.21.0 // indirect 32 | golang.org/x/text v0.16.0 // indirect 33 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 // indirect 34 | google.golang.org/grpc v1.64.0 // indirect 35 | google.golang.org/protobuf v1.34.2 // indirect 36 | ) 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 The Go Authors. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google Inc. nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /internal/minutes3/go.work.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU= 2 | cloud.google.com/go/compute v1.25.1 h1:ZRpHJedLtTpKgr3RV1Fx23NuaAEN1Zfx9hw1u4aJdjU= 3 | cloud.google.com/go/compute v1.25.1/go.mod h1:oopOIR53ly6viBYxaDhBfJwzUAxf1zE//uf3IB011ls= 4 | github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= 5 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 6 | github.com/cncf/xds/go v0.0.0-20240318125728-8a4994d93e50/go.mod h1:5e1+Vvlzido69INQaVO6d87Qn543Xr6nooe9Kz7oBFM= 7 | github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0= 8 | github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew= 9 | github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= 10 | github.com/google/go-pkcs11 v0.2.1-0.20230907215043-c6f79328ddf9/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= 11 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 12 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= 13 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 14 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 15 | golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= 16 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 17 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 18 | google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= 19 | google.golang.org/genproto v0.0.0-20240722135656-d784300faade/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY= 20 | google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3/go.mod h1:kdrSS/OiLkPrNUpzD4aHgCq2rVuC/YRxok32HXZ4vRE= 21 | google.golang.org/genproto/googleapis/bytestream v0.0.0-20240722135656-d784300faade/go.mod h1:5/MT647Cn/GGhwTpXC7QqcaR5Cnee4v4MKCU1/nwnIQ= 22 | -------------------------------------------------------------------------------- /internal/graphql/graphql.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package graphql 6 | 7 | import ( 8 | "bytes" 9 | "encoding/json" 10 | "fmt" 11 | "io/ioutil" 12 | "log" 13 | "net/http" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | type Client struct { 19 | user string 20 | passwd string 21 | } 22 | 23 | func Dial() (*Client, error) { 24 | user, passwd, err := netrcAuth("api.github.com") 25 | if err != nil { 26 | return nil, err 27 | } 28 | return &Client{user: user, passwd: passwd}, nil 29 | } 30 | 31 | type Vars map[string]any 32 | 33 | func (c *Client) GraphQL(query string, vars Vars, reply any) error { 34 | js, err := json.Marshal(struct { 35 | Query string `json:"query"` 36 | Variables any `json:"variables"` 37 | }{ 38 | Query: query, 39 | Variables: vars, 40 | }) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | Retry: 46 | method := "POST" 47 | body := bytes.NewReader(js) 48 | if query == "schema" && vars == nil { 49 | method = "GET" 50 | js = nil 51 | } 52 | req, err := http.NewRequest(method, "https://api.github.com/graphql", body) 53 | if err != nil { 54 | return err 55 | } 56 | if c.user != "" { 57 | req.SetBasicAuth(c.user, c.passwd) 58 | } 59 | 60 | previews := []string{ 61 | "application/vnd.github.inertia-preview+json", // projects 62 | "application/vnd.github.starfox-preview+json", // projects events 63 | "application/vnd.github.elektra-preview+json", // pinned issues 64 | } 65 | req.Header.Set("Accept", strings.Join(previews, ",")) 66 | 67 | resp, err := http.DefaultClient.Do(req) 68 | if err != nil { 69 | return err 70 | } 71 | data, err := ioutil.ReadAll(resp.Body) 72 | if err != nil { 73 | return fmt.Errorf("reading body: %v", err) 74 | } 75 | if resp.StatusCode != 200 { 76 | err := fmt.Errorf("%s\n%s", resp.Status, data) 77 | // TODO(rsc): Could do better here, but this works reasonably well. 78 | // If we're over quota, it could be a while. 79 | if strings.Contains(err.Error(), "wait a few minutes") { 80 | log.Printf("github: %v", err) 81 | time.Sleep(10 * time.Minute) 82 | goto Retry 83 | } 84 | return err 85 | } 86 | 87 | jsreply := struct { 88 | Data any 89 | Errors []struct { 90 | Message string 91 | } 92 | }{ 93 | Data: reply, 94 | } 95 | 96 | err = json.Unmarshal(data, &jsreply) 97 | if err != nil { 98 | return fmt.Errorf("parsing reply: %v", err) 99 | } 100 | 101 | if len(jsreply.Errors) > 0 { 102 | if strings.Contains(jsreply.Errors[0].Message, "rate limit exceeded") { 103 | log.Printf("github: %s", jsreply.Errors[0].Message) 104 | time.Sleep(10 * time.Minute) 105 | goto Retry 106 | } 107 | return fmt.Errorf("graphql error: %s", jsreply.Errors[0].Message) 108 | } 109 | 110 | return nil 111 | } 112 | -------------------------------------------------------------------------------- /internal/minutes/tables.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | ) 11 | 12 | var whoMap = map[string]string{ 13 | "andybons": "andybons", 14 | "bradfitz": "bradfitz", 15 | "gri": "griesemer", 16 | "iant": "ianlancetaylor", 17 | "r": "robpike", 18 | "rsc": "rsc", 19 | "sfrancia": "spf13", 20 | "austin": "aclements", 21 | "julieqiu": "julieqiu", 22 | } 23 | 24 | func gitWho(who string) string { 25 | if whoMap[who] != "" { 26 | return "@" + whoMap[who] 27 | } 28 | fmt.Fprintf(os.Stderr, "warning: unknown attendee %s; assuming GitHub @%s\n", who, who) 29 | return "@" + who 30 | } 31 | 32 | var actionMap = map[string]string{ 33 | "accepted": "no change in consensus; **accepted** 🎉", 34 | "declined": "no change in consensus; **declined**", 35 | "retracted": "proposal retracted by author; **declined**", 36 | "hold": "put on hold", 37 | "on hold": "put on hold", 38 | "unhold": "taken off hold", 39 | "likely accept": "**likely accept**; last call for comments ⏳", 40 | "likely decline": "**likely decline**; last call for comments ⏳", 41 | "discuss": "discussion ongoing", 42 | "removed": "removed from proposal process", 43 | "comment": "commented", 44 | "infeasible": "declined as infeasible", 45 | } 46 | 47 | func updateMsg(old, new, reason string) string { 48 | if msg := updateMsgs[reason]; msg != "" { 49 | return msg 50 | } 51 | return updateMsgs[new] 52 | } 53 | 54 | var updateMsgs = map[string]string{ 55 | "duplicate": ` 56 | This proposal is a duplicate of a previously discussed proposal, as noted above, 57 | and there is no significant new information to justify reopening the discussion. 58 | The issue has therefore been **[declined as a duplicate](https://go.dev/s/proposal-status#declined-as-duplicate)**. 59 | — rsc for the proposal review group 60 | `, 61 | "retracted": ` 62 | This proposal has been **[declined as retracted](https://go.dev/s/proposal-status#declined-as-retracted)**. 63 | — rsc for the proposal review group 64 | `, 65 | "infeasible": ` 66 | This proposal has been **[declined as infeasible](https://go.dev/s/proposal-status#declined-as-infeasible)**. 67 | — rsc for the proposal review group 68 | `, 69 | "Active": ` 70 | This proposal has been added to the [active column](https://go.dev/s/proposal-status#active) of the proposals project 71 | and will now be reviewed at the weekly proposal review meetings. 72 | — rsc for the proposal review group 73 | `, 74 | "Likely Accept": ` 75 | Based on the discussion above, this proposal seems like a **[likely accept](https://go.dev/s/proposal-status#likely-accept)**. 76 | — rsc for the proposal review group 77 | `, 78 | "Likely Decline": ` 79 | Based on the discussion above, this proposal seems like a **[likely decline](https://go.dev/s/proposal-status#likely-decline)**. 80 | — rsc for the proposal review group 81 | `, 82 | "Accepted": ` 83 | No change in consensus, so **[accepted](https://go.dev/s/proposal-status#accepted)**. 🎉 84 | This issue now tracks the work of implementing the proposal. 85 | — rsc for the proposal review group 86 | `, 87 | "Declined": ` 88 | No change in consensus, so **[declined](https://go.dev/s/proposal-status#declined)**. 89 | — rsc for the proposal review group 90 | `, 91 | "Hold": ` 92 | **[Placed on hold](https://go.dev/s/proposal-status#hold)**. 93 | — rsc for the proposal review group 94 | `, 95 | "removed": ` 96 | **Removed from the [proposal process](https://go.dev/s/proposal)**. 97 | This was determined not to be a “significant change to the language, libraries, or tools” 98 | or otherwise of significant importance or interest to the broader Go community. 99 | — rsc for the proposal review group 100 | `, 101 | } 102 | -------------------------------------------------------------------------------- /internal/minutes2/tables.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | ) 11 | 12 | var whoMap = map[string]string{ 13 | "andybons": "andybons", 14 | "bradfitz": "bradfitz", 15 | "gri": "griesemer", 16 | "iant": "ianlancetaylor", 17 | "r": "robpike", 18 | "rsc": "rsc", 19 | "sfrancia": "spf13", 20 | "austin": "aclements", 21 | "julieqiu": "julieqiu", 22 | "adonovan": "adonovan", 23 | "bracewell": "rolandshoemaker", 24 | "roland": "rolandshoemaker", 25 | "cherryyz": "cherrymui", 26 | "cherry": "cherrymui", 27 | } 28 | 29 | func gitWho(who string) string { 30 | if whoMap[who] != "" { 31 | return "@" + whoMap[who] 32 | } 33 | fmt.Fprintf(os.Stderr, "warning: unknown attendee %s; assuming GitHub @%s\n", who, who) 34 | return "@" + who 35 | } 36 | 37 | var actionMap = map[string]string{ 38 | "accepted": "no change in consensus; **accepted** 🎉", 39 | "declined": "no change in consensus; **declined**", 40 | "retracted": "proposal retracted by author; **declined**", 41 | "hold": "put on hold", 42 | "on hold": "put on hold", 43 | "unhold": "taken off hold", 44 | "likely accept": "**likely accept**; last call for comments ⏳", 45 | "likely decline": "**likely decline**; last call for comments ⏳", 46 | "discuss": "discussion ongoing", 47 | "add": "added to minutes", 48 | "removed": "removed from proposal process", 49 | "comment": "commented", 50 | "infeasible": "declined as infeasible", 51 | } 52 | 53 | func updateMsg(old, new, reason string) string { 54 | if msg := updateMsgs[reason]; msg != "" { 55 | return msg 56 | } 57 | return updateMsgs[new] 58 | } 59 | 60 | var updateMsgs = map[string]string{ 61 | "duplicate": ` 62 | This proposal is a duplicate of a previously discussed proposal, as noted above, 63 | and there is no significant new information to justify reopening the discussion. 64 | The issue has therefore been **[declined as a duplicate](https://go.dev/s/proposal-status#declined-as-duplicate)**. 65 | — rsc for the proposal review group 66 | `, 67 | "retracted": ` 68 | This proposal has been **[declined as retracted](https://go.dev/s/proposal-status#declined-as-retracted)**. 69 | — rsc for the proposal review group 70 | `, 71 | "infeasible": ` 72 | This proposal has been **[declined as infeasible](https://go.dev/s/proposal-status#declined-as-infeasible)**. 73 | — rsc for the proposal review group 74 | `, 75 | "obsolete": ` 76 | This proposal has been **[declined as obsolete](https://go.dev/s/proposal-status#declined-as-obsolete)**. 77 | — rsc for the proposal review group 78 | `, 79 | "Active": ` 80 | This proposal has been added to the [active column](https://go.dev/s/proposal-status#active) of the proposals project 81 | and will now be reviewed at the weekly proposal review meetings. 82 | — rsc for the proposal review group 83 | `, 84 | "Likely Accept": ` 85 | Based on the discussion above, this proposal seems like a **[likely accept](https://go.dev/s/proposal-status#likely-accept)**. 86 | — rsc for the proposal review group 87 | `, 88 | "Likely Decline": ` 89 | Based on the discussion above, this proposal seems like a **[likely decline](https://go.dev/s/proposal-status#likely-decline)**. 90 | — rsc for the proposal review group 91 | `, 92 | "Accepted": ` 93 | No change in consensus, so **[accepted](https://go.dev/s/proposal-status#accepted)**. 🎉 94 | This issue now tracks the work of implementing the proposal. 95 | — rsc for the proposal review group 96 | `, 97 | "Declined": ` 98 | No change in consensus, so **[declined](https://go.dev/s/proposal-status#declined)**. 99 | — rsc for the proposal review group 100 | `, 101 | "Hold": ` 102 | **[Placed on hold](https://go.dev/s/proposal-status#hold)**. 103 | — rsc for the proposal review group 104 | `, 105 | "removed": ` 106 | **Removed from the [proposal process](https://go.dev/s/proposal)**. 107 | This was determined not to be a “significant change to the language, libraries, or tools” 108 | or otherwise of significant importance or interest to the broader Go community. 109 | — rsc for the proposal review group 110 | `, 111 | } 112 | -------------------------------------------------------------------------------- /internal/minutes3/tables.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | ) 11 | 12 | var whoMap = map[string]string{ 13 | "andybons": "andybons", 14 | "bradfitz": "bradfitz", 15 | "gri": "griesemer", 16 | "iant": "ianlancetaylor", 17 | "r": "robpike", 18 | "rsc": "rsc", 19 | "sfrancia": "spf13", 20 | "austin": "aclements", 21 | "julieqiu": "julieqiu", 22 | "adonovan": "adonovan", 23 | "bracewell": "rolandshoemaker", 24 | "roland": "rolandshoemaker", 25 | "cherryyz": "cherrymui", 26 | "cherry": "cherrymui", 27 | } 28 | 29 | func gitWho(who string) string { 30 | if whoMap[who] != "" { 31 | return "@" + whoMap[who] 32 | } 33 | fmt.Fprintf(os.Stderr, "warning: unknown attendee %s; assuming GitHub @%s\n", who, who) 34 | return "@" + who 35 | } 36 | 37 | var actionMap = map[string]string{ 38 | "accepted": "no change in consensus; **accepted** 🎉", 39 | "declined": "no change in consensus; **declined**", 40 | "retracted": "proposal retracted by author; **declined**", 41 | "hold": "put on hold", 42 | "on hold": "put on hold", 43 | "unhold": "taken off hold", 44 | "likely accept": "**likely accept**; last call for comments ⏳", 45 | "likely decline": "**likely decline**; last call for comments ⏳", 46 | "discuss": "discussion ongoing", 47 | "add": "added to minutes", 48 | "removed": "removed from proposal process", 49 | "comment": "commented", 50 | "infeasible": "declined as infeasible", 51 | } 52 | 53 | func updateMsg(old, new, reason string) string { 54 | if msg := updateMsgs[reason]; msg != "" { 55 | return msg 56 | } 57 | return updateMsgs[new] 58 | } 59 | 60 | var updateMsgs = map[string]string{ 61 | "duplicate": ` 62 | This proposal is a duplicate of a previously discussed proposal, as noted above, 63 | and there is no significant new information to justify reopening the discussion. 64 | The issue has therefore been **[declined as a duplicate](https://go.dev/s/proposal-status#declined-as-duplicate)**. 65 | — rsc for the proposal review group 66 | `, 67 | "retracted": ` 68 | This proposal has been **[declined as retracted](https://go.dev/s/proposal-status#declined-as-retracted)**. 69 | — rsc for the proposal review group 70 | `, 71 | "infeasible": ` 72 | This proposal has been **[declined as infeasible](https://go.dev/s/proposal-status#declined-as-infeasible)**. 73 | — rsc for the proposal review group 74 | `, 75 | "obsolete": ` 76 | This proposal has been **[declined as obsolete](https://go.dev/s/proposal-status#declined-as-obsolete)**. 77 | — rsc for the proposal review group 78 | `, 79 | "Active": ` 80 | This proposal has been added to the [active column](https://go.dev/s/proposal-status#active) of the proposals project 81 | and will now be reviewed at the weekly proposal review meetings. 82 | — rsc for the proposal review group 83 | `, 84 | "Likely Accept": ` 85 | Based on the discussion above, this proposal seems like a **[likely accept](https://go.dev/s/proposal-status#likely-accept)**. 86 | — rsc for the proposal review group 87 | `, 88 | "Likely Decline": ` 89 | Based on the discussion above, this proposal seems like a **[likely decline](https://go.dev/s/proposal-status#likely-decline)**. 90 | — rsc for the proposal review group 91 | `, 92 | "Accepted": ` 93 | No change in consensus, so **[accepted](https://go.dev/s/proposal-status#accepted)**. 🎉 94 | This issue now tracks the work of implementing the proposal. 95 | — rsc for the proposal review group 96 | `, 97 | "Declined": ` 98 | No change in consensus, so **[declined](https://go.dev/s/proposal-status#declined)**. 99 | — rsc for the proposal review group 100 | `, 101 | "Hold": ` 102 | **[Placed on hold](https://go.dev/s/proposal-status#hold)**. 103 | — rsc for the proposal review group 104 | `, 105 | "removed": ` 106 | **Removed from the [proposal process](https://go.dev/s/proposal)**. 107 | This was determined not to be a “significant change to the language, libraries, or tools” 108 | or otherwise of significant importance or interest to the broader Go community. 109 | — rsc for the proposal review group 110 | `, 111 | } 112 | -------------------------------------------------------------------------------- /schema/schema.tmpl: -------------------------------------------------------------------------------- 1 | {{define "main"}} 2 | // Copyright 2022 The Go Authors. All rights reserved. 3 | // Use of this source code is governed by a BSD-style 4 | // license that can be found in the LICENSE file. 5 | 6 | package schema 7 | 8 | import ( 9 | "bytes" 10 | "encoding/json" 11 | "html/template" 12 | "fmt" 13 | ) 14 | 15 | var ( 16 | _ = template.Must 17 | _ = fmt.Sprint 18 | _ = json.Marshal 19 | ) 20 | 21 | func isNull(js []byte) bool { 22 | js = bytes.Trim(js, " \t\r\n") // Trim JS whitespace 23 | return string(js) == "null" 24 | } 25 | 26 | {{range .Types}} 27 | {{registerType .Name}} 28 | {{end}} 29 | 30 | {{range .Types}} 31 | {{decltype .}} 32 | {{end}} 33 | 34 | {{end}} 35 | 36 | {{define "decltype"}} 37 | {{doc . -}} 38 | {{if eq .Kind "INPUT_OBJECT" -}} 39 | {{declinputobject .}} 40 | {{else if eq .Kind "OBJECT" -}} 41 | {{declobject .}} 42 | {{else if eq .Kind "ENUM" -}} 43 | {{declenum .}} 44 | {{else if eq .Kind "INTERFACE" "UNION" -}} 45 | {{declinterface .}} 46 | {{else if eq .Kind "SCALAR" -}} 47 | {{declscalar .}} 48 | {{end}} 49 | {{end}} 50 | 51 | {{define "comment" -}} 52 | {{- if .}} 53 | {{- strings.TrimSuffix (strings.ReplaceAll . "\n" "\n// ") "."}}. 54 | {{- else}}undocumented. 55 | {{- end}} 56 | {{- end}} 57 | 58 | {{define "doc"}} 59 | // {{.Name}} ({{.Kind}}): {{comment .Description}} 60 | {{end}} 61 | 62 | {{define "declobject" -}} 63 | type {{.Name}} struct { 64 | {{range .Fields -}} 65 | // {{upper .Name}}: {{comment .Description}} 66 | {{if .IsDeprecated -}} 67 | // 68 | // Deprecated: {{comment .Description}} 69 | {{end -}} 70 | {{if .Args -}} 71 | // 72 | // Query arguments: 73 | {{range .Args -}} 74 | // - {{.Name}} {{schematype .Type}} 75 | {{end -}} 76 | {{end -}} 77 | {{upper .Name}} {{gotype .Type}} {{gojson .}} 78 | 79 | {{end}} 80 | } 81 | 82 | {{range .Fields -}} 83 | func (x *{{$.Name}}) Get{{upper .Name}}() {{gotype .Type}} { return x.{{upper .Name}} } 84 | {{end}} 85 | {{end}} 86 | 87 | {{define "declinputobject" -}} 88 | type {{.Name}} struct { 89 | {{range .InputFields -}} 90 | // {{upper .Name}}: {{comment .Description}} 91 | {{if .IsDeprecated -}} 92 | // 93 | // Deprecated: {{comment .Description}} 94 | {{end -}} 95 | // 96 | // GraphQL type: {{schematype .Type}} 97 | {{upper .Name}} {{gotype .Type}} {{gojson .}} 98 | 99 | {{end}} 100 | } 101 | {{end}} 102 | 103 | {{/* TODO: interfaces have common fields*/}} 104 | {{define "declinterface" -}} 105 | // {{.Name}}_Interface: {{comment .Description}} 106 | // 107 | // Possible types: 108 | // 109 | {{range .PossibleTypes -}} 110 | // - {{gotype .}} 111 | {{end -}} 112 | type {{.Name}}_Interface interface { 113 | is{{.Name}}() 114 | {{range .Fields -}} 115 | Get{{upper .Name}}() {{gotype .Type}} 116 | {{end -}} 117 | } 118 | {{range .PossibleTypes -}} 119 | func ({{gotype .}}) is{{$.Name}}() {} 120 | {{end}} 121 | 122 | type {{.Name}} struct { 123 | Interface {{.Name}}_Interface 124 | } 125 | 126 | func (x *{{.Name}}) MarshalJSON() ([]byte, error) { 127 | return json.Marshal(x.Interface) 128 | } 129 | 130 | func (x *{{.Name}}) UnmarshalJSON(js []byte) error { 131 | var info struct { Typename string `json:"__Typename"` } 132 | if err := json.Unmarshal(js, &info); err != nil { 133 | return err 134 | } 135 | switch info.Typename { 136 | default: 137 | if isNull(js) { 138 | return nil 139 | } 140 | return fmt.Errorf("unexpected type %q for {{.Name}}", info.Typename) 141 | {{range .PossibleTypes -}} 142 | case "{{.Name}}": 143 | x.Interface = new({{.Name}}) 144 | {{end -}} 145 | } 146 | return json.Unmarshal(js, x.Interface) 147 | } 148 | {{end}} 149 | 150 | {{define "gojson"}} `json:"{{.Name}},omitempty"`{{end}} 151 | 152 | {{define "declscalar" -}} 153 | type {{.Name}} 154 | {{- if eq .Name "Boolean"}} bool 155 | {{- else if eq .Name "Int"}} int 156 | {{- else if eq .Name "Float"}} float64 157 | {{- else}} string 158 | {{- end}} 159 | {{- end}} 160 | 161 | {{define "gotype"}} 162 | {{- if eq .Kind "OBJECT" "INPUT_OBJECT"}}*{{.Name}} 163 | {{- else if eq .Kind "ENUM" "INTERFACE" "UNION"}}{{.Name}} 164 | {{- else if eq .Kind "LIST"}}[]{{gotype .OfType}} 165 | {{- else if eq .Kind "NON_NULL"}}{{gotype .OfType}} 166 | {{- else if eq .Name "String"}}string 167 | {{- else if eq .Name "Boolean"}}bool 168 | {{- else if eq .Name "Int"}}int 169 | {{- else if eq .Name "Float"}}float64 170 | {{- else if eq .Name "HTML"}}template.HTML 171 | {{- else if and (eq .Kind "SCALAR") .Name}}{{.Name}} 172 | {{- else}}?? {{.Kind}} {{.Name}} 173 | {{- end}} 174 | {{- end}} 175 | 176 | {{define "schematype"}} 177 | {{- if .Name}}{{.Name}} 178 | {{- else if eq .Kind "LIST"}}[{{schematype .OfType}}] 179 | {{- else if eq .Kind "NON_NULL"}}{{schematype .OfType}}! 180 | {{- else}}?? {{.Kind}} {{.Name}} 181 | {{- end}} 182 | {{- end}} 183 | 184 | {{define "declenum" -}} 185 | type {{.Name}} string 186 | 187 | {{range .EnumValues}} 188 | // {{$.Name}}_{{.Name}}: {{comment .Description}} 189 | const {{$.Name}}_{{.Name}} {{$.Name}} = "{{.Name}}" 190 | {{end}} 191 | {{end}} 192 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | 9fans.net/go v0.0.1/go.mod h1:lfPdxjq9v8pVQXUMBCx5EO5oLXWQFlKRQgs1kEkjoIM= 2 | 9fans.net/go v0.0.7 h1:H5CsYJTf99C8EYAQr+uSoEJnLP/iZU8RmDuhyk30iSM= 3 | 9fans.net/go v0.0.7/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0= 4 | dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 5 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 6 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= 7 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 8 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 9 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 10 | github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= 11 | github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= 12 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 13 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 14 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 15 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 16 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 17 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= 18 | golang.org/x/exp v0.0.0-20210405174845-4513512abef3/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4= 19 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 20 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 21 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 22 | golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= 23 | golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= 24 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 25 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 26 | golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 27 | golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 28 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 29 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 30 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 31 | golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= 32 | golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 33 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 34 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 35 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 36 | golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 37 | golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 38 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 39 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 40 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 41 | golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 42 | golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 43 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 44 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 45 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 46 | rsc.io/dbstore v0.1.1 h1:LI4gBJUwbejn0wHJWe0KTwgCM33zUVP3BsNz5y2fkEE= 47 | rsc.io/dbstore v0.1.1/go.mod h1:zI7k1PCSLg9r/T2rBM4E/SctbGmqdtt3kjQSemVh1Rs= 48 | rsc.io/sqlite v0.5.0/go.mod h1:fqHuveM9iIqMzjD0WiZIvKYMty/WqTo2bxE9+zC54WE= 49 | rsc.io/sqlite v1.0.0 h1:zUGL/JDeFfblrma2rToXG+fUsGZe+CShUHI3KlUNwQc= 50 | rsc.io/sqlite v1.0.0/go.mod h1:bRoHdqsJCgrcQDvBeCS454l4kLoFAQKHdwfwpoweIOo= 51 | rsc.io/tmplfunc v0.0.3 h1:53XFQh69AfOa8Tw0Jm7t+GV7KZhOi6jzsCzTtKbMvzU= 52 | rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA= 53 | rsc.io/todo v0.0.3 h1:DWSgkhecAPVCwVYwfR553iqD7RDbb2xkQX2aLgfxY0g= 54 | rsc.io/todo v0.0.3/go.mod h1:+iFBcy95zGkDzsHTv8yqeQ+6biho3V6DQbNbcC67X3A= 55 | -------------------------------------------------------------------------------- /internal/minutes3/gdoc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "flag" 11 | "log" 12 | "net/http" 13 | "os" 14 | "regexp" 15 | "strconv" 16 | "time" 17 | 18 | "golang.org/x/oauth2" 19 | "golang.org/x/oauth2/google" 20 | "google.golang.org/api/option" 21 | "google.golang.org/api/sheets/v4" 22 | ) 23 | 24 | func getClient() *http.Client { 25 | data, err := os.ReadFile("/Users/rsc/.cred/proposal-minutes-gdoc.json") 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | cfg, err := google.JWTConfigFromJSON(data, "https://www.googleapis.com/auth/spreadsheets") 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | return cfg.Client(oauth2.NoContext) 34 | } 35 | 36 | type Doc struct { 37 | Date time.Time 38 | Text []string // top-level text 39 | Who []string 40 | Issues []*Issue 41 | } 42 | 43 | type Issue struct { 44 | Number int 45 | Title string 46 | Details string 47 | Minutes string 48 | Comment string 49 | Notes string 50 | } 51 | 52 | var ( 53 | debugJSON = flag.String("debugjson", "", "json debug mode (save, load)") 54 | ) 55 | 56 | func parseDoc() *Doc { 57 | var spreadsheet *sheets.Spreadsheet 58 | if *debugJSON == "load" { 59 | spreadsheet = new(sheets.Spreadsheet) 60 | data, err := os.ReadFile("debug.json") 61 | if err != nil { 62 | log.Fatal(err) 63 | } 64 | if err := json.Unmarshal(data, spreadsheet); err != nil { 65 | log.Fatal(err) 66 | } 67 | } else { 68 | client := getClient() 69 | 70 | srv, err := sheets.NewService(context.Background(), option.WithHTTPClient(client)) 71 | if err != nil { 72 | log.Fatalf("Unable to retrieve Docs client: %v", err) 73 | } 74 | 75 | id := "1EG7oPcLls9HI_exlHLYuwk2YaN4P5mDc4O2vGyRqZHU" 76 | 77 | spreadsheet, err = srv.Spreadsheets.Get(id).IncludeGridData(true).Do() 78 | if err != nil { 79 | log.Fatalf("Unable to retrieve data from document: %v", err) 80 | } 81 | 82 | if *debugJSON == "save" { 83 | js, _ := json.MarshalIndent(spreadsheet, "", "\t") 84 | js = append(js, '\n') 85 | os.WriteFile("debug.json", js, 0666) 86 | os.Exit(0) 87 | } 88 | } 89 | 90 | d := new(Doc) 91 | var sheet *sheets.Sheet 92 | for _, s := range spreadsheet.Sheets { 93 | if s.Properties.Title == "Proposals" { 94 | sheet = s 95 | break 96 | } 97 | } 98 | if sheet == nil { 99 | log.Fatal("did not find Proposals sheet") 100 | } 101 | 102 | const ( 103 | column = -'A' 104 | issueColumn = column + 'A' 105 | statusColumn = column + 'B' 106 | titleColumn = column + 'D' 107 | detailsColumn = column + 'E' 108 | 109 | metaColumn = column + 'B' 110 | metaValueColumn = column + 'D' 111 | 112 | maxColumn = column + 'E' 113 | ) 114 | blank := 0 115 | meta := true 116 | for _, data := range sheet.Data { 117 | for r, row := range data.RowData { 118 | cells := make([]string, max(len(row.Values), maxColumn+1)) 119 | for c, cell := range row.Values { 120 | v := cell.EffectiveValue 121 | if v != nil && v.StringValue != nil { 122 | cells[c] = *v.StringValue 123 | } 124 | } 125 | if cells[issueColumn] == "Issue" { 126 | meta = false 127 | continue 128 | } 129 | if meta { 130 | val := cells[metaValueColumn] 131 | switch cells[metaColumn] { 132 | case "Date:": 133 | var day int 134 | if len(row.Values) > metaValueColumn { 135 | v := row.Values[metaValueColumn].EffectiveValue 136 | if v != nil && v.NumberValue != nil { 137 | day = int(*v.NumberValue) 138 | } 139 | } 140 | if day == 0 { 141 | log.Printf("%c%d: bad date %q", metaValueColumn-column, r+1, val) 142 | failure = true 143 | continue 144 | } 145 | var day0 = time.Date(1899, time.December, 30, 12, 0, 0, 0, time.UTC) 146 | d.Date = day0.Add(time.Duration(time.Duration(day) * 24 * time.Hour)) 147 | case "Who:": 148 | d.Who = regexp.MustCompile(`[,\s]+`).Split(val, -1) 149 | case "": 150 | // ignore 151 | default: 152 | log.Printf("%c%d: unknown meta key %q", metaColumn-column, r+1, cells[metaColumn]) 153 | failure = true 154 | } 155 | continue 156 | } 157 | 158 | var issue Issue 159 | issue.Minutes = cells[statusColumn] 160 | issue.Title = cells[titleColumn] 161 | issue.Details = cells[detailsColumn] 162 | num := cells[issueColumn] 163 | if num == "" && issue == (Issue{}) { 164 | blank++ 165 | continue 166 | } 167 | if blank > 10 { 168 | log.Printf("found stray non-empty row %d", r+1) 169 | failure = true 170 | } 171 | n, err := strconv.Atoi(num) 172 | if err != nil { 173 | log.Printf("%c%d: bad issue number %q", issueColumn-column, r+1, num) 174 | failure = true 175 | continue 176 | } 177 | issue.Number = n 178 | d.Issues = append(d.Issues, &issue) 179 | } 180 | } 181 | 182 | if d.Date.IsZero() { 183 | log.Printf("spreadsheet Date: missing") 184 | failure = true 185 | } else if time.Since(d.Date) > 5*24*time.Hour || -time.Since(d.Date) > 24*time.Hour { 186 | log.Printf("spreadsheet Date: too old") 187 | failure = true 188 | } 189 | 190 | return d 191 | } 192 | -------------------------------------------------------------------------------- /internal/minutes2/gdoc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "log" 11 | "net/http" 12 | "os" 13 | "strconv" 14 | "strings" 15 | 16 | "golang.org/x/oauth2" 17 | "golang.org/x/oauth2/google" 18 | "google.golang.org/api/docs/v1" 19 | "google.golang.org/api/option" 20 | ) 21 | 22 | func getClient() *http.Client { 23 | data, err := os.ReadFile("/Users/rsc/.cred/proposal-minutes-gdoc.json") 24 | if err != nil { 25 | log.Fatal(err) 26 | } 27 | cfg, err := google.JWTConfigFromJSON(data, "https://www.googleapis.com/auth/documents") 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | return cfg.Client(oauth2.NoContext) 32 | } 33 | 34 | type Doc struct { 35 | Text []string // top-level text 36 | Who []string 37 | Issues []*Issue 38 | } 39 | 40 | type Issue struct { 41 | Number int 42 | Title string 43 | Details string 44 | Minutes string 45 | Comment string 46 | Notes string 47 | } 48 | 49 | func parseDoc() *Doc { 50 | var doc *docs.Document 51 | if true { 52 | client := getClient() 53 | 54 | srv, err := docs.NewService(context.Background(), option.WithHTTPClient(client)) 55 | if err != nil { 56 | log.Fatalf("Unable to retrieve Docs client: %v", err) 57 | } 58 | 59 | docId := "1Ri8QwTL6Scwm1Ke1cd1gIZIYwBffViuOCIRJDYARZU8" 60 | 61 | /* 62 | resp, err := srv.Documents.BatchUpdate(docId, &docs.BatchUpdateDocumentRequest{ 63 | Requests: []*docs.Request{ 64 | { 65 | InsertText: &docs.InsertTextRequest{ 66 | Location: &docs.Location{ 67 | Index: 1, 68 | }, 69 | Text: "A", 70 | }, 71 | }, 72 | { 73 | InsertText: &docs.InsertTextRequest{ 74 | Location: &docs.Location{ 75 | Index: 2, 76 | }, 77 | Text: "B", 78 | }, 79 | }, 80 | }, 81 | }).Do() 82 | if err != nil { 83 | log.Fatal(err) 84 | } 85 | js, err := json.Marshal(resp) 86 | js = append(js, '\n') 87 | os.Stdout.Write(js) 88 | return nil 89 | */ 90 | 91 | doc, err = srv.Documents.Get(docId).Do() 92 | if err != nil { 93 | log.Fatalf("Unable to retrieve data from document: %v", err) 94 | } 95 | } else { 96 | doc = new(docs.Document) 97 | data, err := os.ReadFile("x.json") 98 | if err != nil { 99 | log.Fatal(err) 100 | } 101 | if err := json.Unmarshal(data, doc); err != nil { 102 | log.Fatal(err) 103 | } 104 | } 105 | 106 | d := new(Doc) 107 | top := "" 108 | for _, elem := range doc.Body.Content { 109 | if para := elem.Paragraph; para != nil { 110 | content := "" 111 | for _, elem := range para.Elements { 112 | if run := elem.TextRun; run != nil { 113 | content += run.Content 114 | } 115 | } 116 | top += strings.Trim(strings.ReplaceAll(content, "\v", "\n"), "\n") + "\n" 117 | } 118 | if table := elem.Table; table != nil { 119 | rest, line := cutLastLine(top) 120 | if strings.HasPrefix(line, "#NNNNN") { 121 | continue 122 | } 123 | if !strings.HasPrefix(line, "#") { 124 | log.Fatalf("bad issue: %s", line) 125 | } 126 | num, title, ok := strings.Cut(line, " ") 127 | if !ok { 128 | log.Fatalf("bad issue2: %s", line) 129 | } 130 | n, err := strconv.Atoi(strings.TrimPrefix(num, "#")) 131 | if err != nil { 132 | log.Fatalf("bad issue3: %s", line) 133 | } 134 | issue := &Issue{ 135 | Number: n, 136 | Title: title, 137 | } 138 | d.Issues = append(d.Issues, issue) 139 | top = rest 140 | for _, row := range table.TableRows { 141 | for _, cell := range row.TableCells { 142 | content := "" 143 | for _, elem := range cell.Content { 144 | if para := elem.Paragraph; para != nil { 145 | for _, elem := range para.Elements { 146 | if run := elem.TextRun; run != nil { 147 | content += run.Content 148 | } 149 | } 150 | } 151 | } 152 | content = strings.ReplaceAll(content, "\v", "\n") 153 | if strings.HasPrefix(content, "Minutes:") { 154 | issue.Minutes = strings.TrimSpace(strings.TrimPrefix(content, "Minutes:")) 155 | continue 156 | } 157 | first, rest, _ := strings.Cut(content, "\n") 158 | if !strings.HasSuffix(first, ":") { 159 | log.Fatalf("missing colon: %s", content) 160 | } 161 | rest = strings.Trim(rest, "\n") 162 | if rest != "" { 163 | rest += "\n" 164 | } 165 | if rest == "None\n" || rest == "TBD\n" { 166 | rest = "" 167 | } 168 | switch { 169 | case strings.HasPrefix(first, "Proposal details"): 170 | issue.Details = rest 171 | case strings.HasPrefix(first, "Comment"): 172 | issue.Comment = rest 173 | case strings.HasPrefix(first, "Private notes"), strings.HasPrefix(first, "Discussion notes"): 174 | issue.Notes = rest 175 | default: 176 | log.Fatalf("unknown cell: %s", content) 177 | } 178 | } 179 | } 180 | } 181 | } 182 | for _, line := range strings.Split(top, "\n") { 183 | line = strings.TrimSpace(line) 184 | if line == "" { 185 | continue 186 | } 187 | if strings.HasPrefix(line, "Attendees:") { 188 | d.Who = strings.Fields(strings.TrimPrefix(line, "Attendees:")) 189 | for i, a := range d.Who { 190 | d.Who[i] = strings.Trim(a, ",") 191 | } 192 | } 193 | d.Text = append(d.Text, line) 194 | } 195 | 196 | return d 197 | /* 198 | content := doc.Body.Content 199 | 200 | 201 | js, err := json.MarshalIndent(doc, "", "\t") 202 | if err != nil { 203 | log.Fatal(err) 204 | } 205 | os.Stdout.Write(append(js, '\n')) 206 | */ 207 | } 208 | 209 | func cutLastLine(s string) (rest, line string) { 210 | s = strings.TrimRight(s, "\n") 211 | i := strings.LastIndex(s, "\n") 212 | return s[:i+1], s[i+1:] 213 | } 214 | 215 | /* 216 | func main() { 217 | doc := parseDoc() 218 | js, err := json.MarshalIndent(doc, "", "\t") 219 | if err != nil { 220 | log.Fatal(err) 221 | } 222 | os.Stdout.Write(append(js, '\n')) 223 | } 224 | */ 225 | -------------------------------------------------------------------------------- /schema/generate.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build ignore 6 | 7 | // go run generate.go downloads the latest GraphQL schema from GitHub 8 | // and generates corresponding Go data structures in schema.go. 9 | package main 10 | 11 | import ( 12 | "bytes" 13 | "encoding/json" 14 | "log" 15 | "os" 16 | "os/exec" 17 | "sort" 18 | "strings" 19 | "text/template" 20 | "unicode" 21 | "unicode/utf8" 22 | 23 | "rsc.io/github/internal/graphql" 24 | "rsc.io/tmplfunc" 25 | ) 26 | 27 | func main() { 28 | log.SetFlags(log.Lshortfile) 29 | data, err := os.ReadFile("schema.js") 30 | if err != nil { 31 | c, err := graphql.Dial() 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | 36 | type schema struct { 37 | Types []Type `json:"types"` 38 | } 39 | var reply any 40 | err = c.GraphQL("schema", nil, &reply) 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | js, err := json.MarshalIndent(reply, "", "\t") 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | js = append(js, '\n') 49 | if err := os.WriteFile("schema.js", js, 0666); err != nil { 50 | log.Fatal(err) 51 | } 52 | data = js 53 | } 54 | var x struct { 55 | Schema *Schema `json:"__schema"` 56 | } 57 | if err := json.Unmarshal(data, &x); err != nil { 58 | log.Fatal(err) 59 | } 60 | 61 | tmpl := template.New("") 62 | tmpl.Funcs(template.FuncMap{ 63 | "registerType": registerType, 64 | "link": link, 65 | "strings": func() stringsPkg { return stringsPkg{} }, 66 | "upper": upper, 67 | }) 68 | if err := tmplfunc.ParseFiles(tmpl, "schema.tmpl"); err != nil { 69 | log.Fatal(err) 70 | } 71 | var b bytes.Buffer 72 | if err := tmpl.ExecuteTemplate(&b, "main", x.Schema); err != nil { 73 | log.Fatal(err) 74 | } 75 | 76 | if err := os.WriteFile("schema.go", b.Bytes(), 0666); err != nil { 77 | log.Fatal(err) 78 | } 79 | out, err := exec.Command("gofmt", "-w", "schema.go").CombinedOutput() 80 | if err != nil { 81 | log.Fatalf("gofmt schema.go: %v\n%s", err, out) 82 | } 83 | } 84 | 85 | type stringsPkg struct{} 86 | 87 | func (stringsPkg) ReplaceAll(s, old, new string) string { 88 | return strings.ReplaceAll(s, old, new) 89 | } 90 | 91 | func (stringsPkg) TrimSuffix(s, suffix string) string { 92 | return strings.TrimSuffix(s, suffix) 93 | } 94 | 95 | var types []string 96 | 97 | func registerType(name string) string { 98 | types = append(types, name) 99 | return "" 100 | } 101 | 102 | func upper(s string) string { 103 | r, size := utf8.DecodeRuneInString(s) 104 | return string(unicode.ToUpper(r)) + s[size:] 105 | } 106 | 107 | var docReplacer *strings.Replacer 108 | 109 | func link(text string) string { 110 | if docReplacer == nil { 111 | sort.Strings(types) 112 | sort.SliceStable(types, func(i, j int) bool { 113 | return len(types[i]) > len(types[j]) 114 | }) 115 | var args []string 116 | for _, typ := range types { 117 | args = append(args, typ, "["+typ+"]") 118 | } 119 | docReplacer = strings.NewReplacer(args...) 120 | } 121 | return docReplacer.Replace(text) 122 | } 123 | 124 | type Directive struct { 125 | Name string `json:"name"` 126 | Args []*InputValue `json:"args"` 127 | Description string `json:"description,omitempty"` 128 | Locations []*DirectiveLocation `json:"locations,omitempty"` 129 | } 130 | 131 | type DirectiveLocation string // an enum 132 | 133 | type EnumValue struct { 134 | Name string `json:"name,omitempty"` 135 | Description string `json:"description,omitempty"` 136 | DeprecationReason string `json:"deprecationReason,omitempty"` 137 | IsDeprecated bool `json:"isDeprecated,omitempty"` 138 | } 139 | 140 | type Field struct { 141 | Name string `json:"name,omitempty"` 142 | Description string `json:"description,omitempty"` 143 | Args []*InputValue `json:"args,omitempty"` 144 | DeprecationReason string `json:"deprecationReason,omitempty"` 145 | IsDeprecated bool `json:"isDeprecated,omitempty"` 146 | Type *ShortType `json:"type,omitempty"` 147 | } 148 | 149 | type InputValue struct { 150 | Name string `json:"name,omitempty"` 151 | Description string `json:"description,omitempty"` 152 | DefaultValue any `json:"defaultValue,omitempty"` 153 | DeprecationReason string `json:"deprecationReason,omitempty"` 154 | IsDeprecated bool `json:"isDeprecated,omitempty"` 155 | Type *ShortType `json:"type,omitempty"` 156 | } 157 | 158 | type Schema struct { 159 | Directives []*Directive `json:"directives,omitempty"` 160 | MutationType *ShortType `json:"mutationType,omitempty"` 161 | QueryType *ShortType `json:"queryType,omitempty"` 162 | SubscriptionType *ShortType `json:"subscriptionType,omitempty"` 163 | Types []*Type `json:"types,omitempty"` 164 | } 165 | 166 | type Type struct { 167 | Name string `json:"name,omitempty"` 168 | Description string `json:"description,omitempty"` 169 | EnumValues []*EnumValue `json:"enumValues,omitempty"` 170 | Fields []*Field `json:"fields,omitempty"` 171 | InputFields []*InputValue `json:"inputFields,omitempty"` 172 | Interfaces []*ShortType `json:"interfaces,omitempty"` 173 | Kind string `json:"kind,omitempty"` 174 | OfType *ShortType `json:"ofType,omitempty"` 175 | PossibleTypes []*ShortType `json:"possibleTypes,omitempty"` 176 | } 177 | 178 | type TypeKind string // an enum 179 | 180 | type ShortType struct { 181 | Name string `json:"name,omitempty"` 182 | Kind string `json:"kind,omitempty"` 183 | OfType *ShortType `json:"ofType,omitempty"` 184 | } 185 | 186 | const query = ` 187 | query { 188 | __schema { 189 | directives { 190 | args ` + inputValue + ` 191 | description 192 | name 193 | locations 194 | } 195 | mutationType ` + shortType + ` 196 | queryType ` + shortType + ` 197 | subscriptionType ` + shortType + ` 198 | types { 199 | description 200 | enumValues { 201 | deprecationReason 202 | description 203 | isDeprecated 204 | name 205 | } 206 | fields { 207 | args ` + inputValue + ` 208 | deprecationReason 209 | description 210 | isDeprecated 211 | name 212 | type ` + shortType + ` 213 | } 214 | inputFields ` + inputValue + ` 215 | interfaces ` + shortType + ` 216 | kind 217 | name 218 | ofType ` + shortType + ` 219 | possibleTypes ` + shortType + ` 220 | } 221 | } 222 | } 223 | ` 224 | 225 | const inputValue = ` 226 | { 227 | defaultValue 228 | deprecationReason 229 | isDeprecated 230 | description 231 | name 232 | type ` + shortType + ` 233 | } 234 | ` 235 | 236 | const shortType = ` 237 | { 238 | name 239 | kind 240 | ofType { 241 | name 242 | kind 243 | ofType { 244 | name 245 | kind 246 | ofType { 247 | name 248 | kind 249 | } 250 | } 251 | } 252 | } 253 | ` 254 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package github provides idiomatic Go APIs for accessing basic GitHub issue operations. 6 | // 7 | // The entire GitHub API can be accessed by using the [Client] with GraphQL schema from 8 | // [rsc.io/github/schema]. 9 | package github 10 | 11 | import ( 12 | "bytes" 13 | "encoding/json" 14 | "fmt" 15 | "io/ioutil" 16 | "log" 17 | "net/http" 18 | "strings" 19 | "time" 20 | 21 | "rsc.io/github/schema" 22 | ) 23 | 24 | // A Client is an authenticated client for accessing the GitHub GraphQL API. 25 | // Client provides convenient methods for common operations. 26 | // To build others, see the [GraphQLQuery] and [GraphQLMutation] methods. 27 | type Client struct { 28 | token string 29 | } 30 | 31 | // Dial returns a Client authenticating as user. 32 | // Authentication credentials are loaded from $HOME/.netrc 33 | // using the 'api.github.com' entry, which should contain a 34 | // GitHub personal access token. 35 | // If user is the empty string, Dial uses the first line in .netrc 36 | // listed for api.github.com. 37 | // 38 | // For example, $HOME/.netrc might contain: 39 | // 40 | // machine api.github.com login ken password ghp_123456789abcdef123456789abcdef12345 41 | func Dial(user string) (*Client, error) { 42 | _, passwd, err := netrcAuth("api.github.com", user) 43 | if err != nil { 44 | return nil, err 45 | } 46 | return &Client{token: passwd}, nil 47 | } 48 | 49 | // NewClient returns a new client using the given GitHub personal access token (of the form "ghp_...."). 50 | func NewClient(token string) *Client { 51 | return &Client{token: token} 52 | } 53 | 54 | // A Vars is a binding of GraphQL variables to JSON-able values (usually strings). 55 | type Vars map[string]any 56 | 57 | // GraphQLQuery runs a single query with the bound variables. 58 | // For example, to look up a repository ID: 59 | // 60 | // func repoID(org, name string) (string, error) { 61 | // graphql := ` 62 | // query($Org: String!, $Repo: String!) { 63 | // repository(owner: $Org, name: $Repo) { 64 | // id 65 | // } 66 | // } 67 | // ` 68 | // vars := Vars{"Org": org, "Repo": repo} 69 | // q, err := c.GraphQLQuery(graphql, vars) 70 | // if err != nil { 71 | // return "", err 72 | // } 73 | // return string(q.Repository.Id), nil 74 | // } 75 | // 76 | // (This is roughly the implementation of the [Client.Repo] method.) 77 | func (c *Client) GraphQLQuery(query string, vars Vars) (*schema.Query, error) { 78 | var reply schema.Query 79 | if err := c.graphQL(query, vars, &reply); err != nil { 80 | return nil, err 81 | } 82 | return &reply, nil 83 | } 84 | 85 | // GraphQLMutation runs a single mutation with the bound variables. 86 | // For example, to edit an issue comment: 87 | // 88 | // func editComment(commentID, body string) error { 89 | // graphql := ` 90 | // mutation($Comment: ID!, $Body: String!) { 91 | // updateIssueComment(input: {id: $Comment, body: $Body}) { 92 | // clientMutationId 93 | // } 94 | // } 95 | // ` 96 | // _, err := c.GraphQLMutation(graphql, Vars{"Comment": commentID, "Body": body}) 97 | // return err 98 | // } 99 | // 100 | // (This is roughly the implementation of the [Client.EditIssueComment] method.) 101 | func (c *Client) GraphQLMutation(query string, vars Vars) (*schema.Mutation, error) { 102 | var reply schema.Mutation 103 | if err := c.graphQL(query, vars, &reply); err != nil { 104 | return nil, err 105 | } 106 | return &reply, nil 107 | } 108 | 109 | func (c *Client) graphQL(query string, vars Vars, reply any) error { 110 | js, err := json.Marshal(struct { 111 | Query string `json:"query"` 112 | Variables any `json:"variables"` 113 | }{ 114 | Query: query, 115 | Variables: vars, 116 | }) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | Retry: 122 | method := "POST" 123 | body := bytes.NewReader(js) 124 | if query == "schema" && vars == nil { 125 | method = "GET" 126 | js = nil 127 | } 128 | req, err := http.NewRequest(method, "https://api.github.com/graphql", body) 129 | if err != nil { 130 | return err 131 | } 132 | if c.token != "" { 133 | req.Header.Set("Authorization", "Bearer "+c.token) 134 | } 135 | 136 | previews := []string{ 137 | "application/vnd.github.inertia-preview+json", // projects 138 | "application/vnd.github.starfox-preview+json", // projects events 139 | "application/vnd.github.elektra-preview+json", // pinned issues 140 | } 141 | req.Header.Set("Accept", strings.Join(previews, ",")) 142 | 143 | resp, err := http.DefaultClient.Do(req) 144 | if err != nil { 145 | return err 146 | } 147 | data, err := ioutil.ReadAll(resp.Body) 148 | if err != nil { 149 | return fmt.Errorf("reading body: %v", err) 150 | } 151 | if resp.StatusCode != 200 { 152 | err := fmt.Errorf("%s\n%s", resp.Status, data) 153 | // TODO(rsc): Could do better here, but this works reasonably well. 154 | // If we're over quota, it could be a while. 155 | if strings.Contains(err.Error(), "wait a few minutes") { 156 | log.Printf("github: %v", err) 157 | time.Sleep(10 * time.Minute) 158 | goto Retry 159 | } 160 | return err 161 | } 162 | 163 | jsreply := struct { 164 | Data any 165 | Errors []struct { 166 | Message string 167 | } 168 | }{ 169 | Data: reply, 170 | } 171 | 172 | err = json.Unmarshal(data, &jsreply) 173 | if err != nil { 174 | return fmt.Errorf("parsing reply: %v", err) 175 | } 176 | 177 | if len(jsreply.Errors) > 0 { 178 | if strings.Contains(jsreply.Errors[0].Message, "rate limit exceeded") { 179 | log.Printf("github: %s", jsreply.Errors[0].Message) 180 | time.Sleep(10 * time.Minute) 181 | goto Retry 182 | } 183 | if strings.Contains(jsreply.Errors[0].Message, "submitted too quickly") { 184 | log.Printf("github: %s", jsreply.Errors[0].Message) 185 | time.Sleep(5 * time.Second) 186 | goto Retry 187 | } 188 | for i, line := range strings.Split(query, "\n") { 189 | log.Print(i+1, line) 190 | } 191 | return fmt.Errorf("graphql error: %s", jsreply.Errors[0].Message) 192 | } 193 | 194 | return nil 195 | } 196 | 197 | func collect[Schema, Out any](c *Client, graphql string, vars Vars, transform func(Schema) Out, 198 | page func(*schema.Query) pager[Schema]) ([]Out, error) { 199 | var cursor string 200 | var list []Out 201 | for { 202 | if cursor != "" { 203 | vars["Cursor"] = cursor 204 | } 205 | q, err := c.GraphQLQuery(graphql, vars) 206 | if err != nil { 207 | return list, err 208 | } 209 | p := page(q) 210 | if p == nil { 211 | break 212 | } 213 | list = append(list, apply(transform, p.GetNodes())...) 214 | info := p.GetPageInfo() 215 | cursor = info.EndCursor 216 | if cursor == "" || !info.HasNextPage { 217 | break 218 | } 219 | } 220 | return list, nil 221 | } 222 | 223 | type pager[T any] interface { 224 | GetPageInfo() *schema.PageInfo 225 | GetNodes() []T 226 | } 227 | 228 | func apply[In, Out any](f func(In) Out, x []In) []Out { 229 | var out []Out 230 | for _, in := range x { 231 | out = append(out, f(in)) 232 | } 233 | return out 234 | } 235 | 236 | func toTime(s schema.DateTime) time.Time { 237 | t, err := time.ParseInLocation(time.RFC3339Nano, string(s), time.UTC) 238 | if err != nil { 239 | return time.Time{} 240 | } 241 | return t 242 | } 243 | 244 | func toDate(s schema.Date) time.Time { 245 | t, err := time.ParseInLocation("2006-01-02", string(s), time.UTC) 246 | if err != nil { 247 | return time.Time{} 248 | } 249 | return t 250 | } 251 | -------------------------------------------------------------------------------- /issuedb/todo.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "crypto/sha256" 9 | "encoding/json" 10 | "fmt" 11 | "io/ioutil" 12 | "log" 13 | "os" 14 | "path/filepath" 15 | "strings" 16 | "time" 17 | 18 | "rsc.io/todo/task" 19 | ) 20 | 21 | type ghItem struct { 22 | Type string 23 | URL string 24 | Time time.Time 25 | Issue ghIssue 26 | Event ghIssueEvent 27 | Comment ghIssueComment 28 | } 29 | 30 | const timeFormat = "2006-01-02 15:04:05 -0700" 31 | 32 | func todo(proj *ProjectSync) { 33 | println("#", proj.Name) 34 | root := filepath.Join(os.Getenv("HOME"), "todo/github", filepath.Base(proj.Name)) 35 | data, _ := ioutil.ReadFile(filepath.Join(root, "synctime")) 36 | var syncTime time.Time 37 | if len(data) > 0 { 38 | t, err := time.Parse(time.RFC3339, string(data)) 39 | if err != nil { 40 | log.Fatalf("parsing %s: %v", filepath.Join(root, "synctime"), err) 41 | } 42 | syncTime = t 43 | } 44 | 45 | l := task.OpenList(root) 46 | 47 | // Start 10 minutes back just in case there is time skew in some way on GitHub. 48 | // (If this is not good enough, we can always impose our own sequence numbering 49 | // in the RawJSON table.) 50 | startTime := syncTime.Add(-10 * time.Minute) 51 | endTime := syncTime 52 | process(proj, startTime, func(proj *ProjectSync, issue int64, items []*ghItem) { 53 | fmt.Fprintf(os.Stderr, "%v#%v\n", proj.Name, issue) 54 | if end := items[len(items)-1].Time; endTime.Before(end) { 55 | endTime = end 56 | } 57 | todoIssue(l, proj, issue, items) 58 | }) 59 | 60 | if err := ioutil.WriteFile(filepath.Join(root, "synctime"), []byte(endTime.Local().Format(time.RFC3339)), 0666); err != nil { 61 | log.Fatal(err) 62 | } 63 | } 64 | 65 | func todoIssue(l *task.List, proj *ProjectSync, issue int64, items []*ghItem) { 66 | id := fmt.Sprint(issue) 67 | t, err := l.Read(id) 68 | var last time.Time 69 | if err != nil { 70 | if items[0].Type != "/issues" { 71 | log.Printf("sync: missing creation for %v/%v", proj.Name, issue) 72 | return 73 | } 74 | it := &items[0].Issue 75 | last = items[0].Time 76 | hdr := map[string]string{ 77 | "url": it.HTMLURL, 78 | "author": it.User.Login, 79 | "title": it.Title, 80 | "updated": last.Format(timeFormat), 81 | } 82 | syncHdr(hdr, hdr, it) 83 | t, err = l.Create(id, items[0].Time.Local(), hdr, []byte(bodyText(it.User.Login, "reported", it.Body))) 84 | if err != nil { 85 | log.Fatal(err) 86 | } 87 | items = items[1:] 88 | } else { 89 | last, err = time.Parse(timeFormat, t.Header("updated")) 90 | if err != nil { 91 | log.Fatalf("sync: bad updated time in %v", issue) 92 | } 93 | } 94 | 95 | haveEID := make(map[string]bool) 96 | for _, eid := range t.EIDs() { 97 | haveEID[eid] = true 98 | } 99 | 100 | for _, it := range items { 101 | if last.Before(it.Time) { 102 | last = it.Time 103 | } 104 | h := sha256.Sum256([]byte(it.URL)) 105 | eid := fmt.Sprintf("%x", h)[:8] 106 | if haveEID[eid] { 107 | continue 108 | } 109 | 110 | switch it.Type { 111 | default: 112 | log.Fatalf("unexpected type %s", it.Type) 113 | case "/issues": 114 | continue 115 | case "/issues/events": 116 | ev := &it.Event 117 | hdr := map[string]string{ 118 | "#id": eid, 119 | "updated": last.Local().Format(timeFormat), 120 | } 121 | what := "@" + ev.Actor.Login + " " + ev.Event 122 | switch ev.Event { 123 | case "closed", "merged", "referenced": 124 | what += ": " + "https://github.com/" + proj.Name + "/commit/" + ev.CommitID 125 | if ev.Event == "closed" || ev.Event == "merged" { 126 | hdr["closed"] = it.Time.Local().Format(time.RFC3339) 127 | } 128 | case "assigned", "unassigned": 129 | var list []string 130 | for _, who := range ev.Assignees { 131 | list = append(list, who.Login) 132 | } 133 | what += ": " + strings.Join(list, ", ") 134 | if ev.Event == "assigned" { 135 | hdr["assign"] = addList(t.Header("assign"), list) 136 | } else { 137 | hdr["assign"] = deleteList(t.Header("assign"), list) 138 | } 139 | case "labeled", "unlabeled": 140 | var list []string 141 | for _, lab := range ev.Labels { 142 | list = append(list, lab.Name) 143 | } 144 | what += ": " + strings.Join(list, ", ") 145 | if ev.Event == "labeled" { 146 | hdr["label"] = addList(t.Header("label"), list) 147 | } else { 148 | hdr["label"] = deleteList(t.Header("label"), list) 149 | } 150 | case "milestoned": 151 | what += ": " + ev.Milestone.Title 152 | hdr["milestone"] = ev.Milestone.Title 153 | case "demilestoned": 154 | hdr["milestone"] = "" 155 | case "renamed": 156 | what += ":\n\t" + ev.Rename.From + " →\n\t" + ev.Rename.To 157 | } 158 | if err := l.Write(t, it.Time.Local(), hdr, []byte(what)); err != nil { 159 | log.Fatal(err) 160 | } 161 | case "/issues/comments": 162 | com := &it.Comment 163 | hdr := map[string]string{ 164 | "#id": eid, 165 | "#url": com.HTMLURL, 166 | "updated": last.Local().Format(timeFormat), 167 | } 168 | if err := l.Write(t, it.Time.Local(), hdr, []byte(bodyText(com.User.Login, "commented", com.Body))); err != nil { 169 | log.Fatal(err) 170 | } 171 | } 172 | } 173 | } 174 | 175 | func addList(old string, add []string) string { 176 | have := make(map[string]bool) 177 | for _, name := range strings.Split(old, ", ") { 178 | have[name] = true 179 | } 180 | for _, name := range add { 181 | if !have[name] { 182 | old += ", " + name 183 | have[name] = true 184 | } 185 | } 186 | return old 187 | } 188 | 189 | func deleteList(old string, del []string) string { 190 | drop := make(map[string]bool) 191 | for _, name := range del { 192 | drop[name] = true 193 | } 194 | var list []string 195 | for _, name := range strings.Split(old, ", ") { 196 | if name != "" && !drop[name] { 197 | list = append(list, name) 198 | } 199 | } 200 | return strings.Join(list, ", ") 201 | } 202 | 203 | func syncHdr(old, hdr map[string]string, it *ghIssue) { 204 | pr := "" 205 | if it.PullRequest != nil { 206 | pr = "pr" 207 | } 208 | if old["pr"] != pr { 209 | hdr["pr"] = pr 210 | } 211 | if old["milestone"] != it.Milestone.Title { 212 | hdr["milestone"] = it.Milestone.Title 213 | } 214 | locked := "" 215 | if it.Locked { 216 | locked := it.ActiveLockReason 217 | if locked == "" { 218 | locked = "locked" 219 | } 220 | } 221 | if old["locked"] != locked { 222 | hdr["locked"] = locked 223 | } 224 | closed := "" 225 | if it.ClosedAt != "" { 226 | closed = it.ClosedAt 227 | } 228 | if old["closed"] != closed { 229 | hdr["closed"] = closed 230 | } 231 | var list []string 232 | for _, who := range it.Assignees { 233 | list = append(list, who.Login) 234 | } 235 | all := strings.Join(list, ", ") 236 | if old["assign"] != all { 237 | hdr["assign"] = all 238 | } 239 | list = nil 240 | for _, lab := range it.Labels { 241 | list = append(list, lab.Name) 242 | } 243 | all = strings.Join(list, ", ") 244 | if old["label"] != all { 245 | hdr["label"] = all 246 | } 247 | } 248 | 249 | func process(proj *ProjectSync, since time.Time, do func(proj *ProjectSync, issue int64, item []*ghItem)) { 250 | rows, err := db.Query("select * from RawJSON where Project = ? and Time >= ? order by Issue, Time, Type", proj.Name, since.UTC().Format(time.RFC3339)) 251 | if err != nil { 252 | log.Fatalf("sql: %v", err) 253 | } 254 | 255 | var items []*ghItem 256 | var lastIssue int64 257 | for rows.Next() { 258 | var raw RawJSON 259 | if err := rows.Scan(&raw.URL, &raw.Project, &raw.Issue, &raw.Type, &raw.JSON, &raw.Time); err != nil { 260 | log.Fatalf("sql scan RawJSON: %v", err) 261 | } 262 | if raw.Issue != lastIssue { 263 | if len(items) > 0 { 264 | do(proj, lastIssue, items) 265 | } 266 | items = items[:0] 267 | lastIssue = raw.Issue 268 | } 269 | 270 | var ev ghIssueEvent 271 | var com ghIssueComment 272 | var issue ghIssue 273 | switch raw.Type { 274 | default: 275 | log.Fatalf("unknown type %s", raw.Type) 276 | case "/issues/comments": 277 | err = json.Unmarshal(raw.JSON, &com) 278 | case "/issues/events": 279 | err = json.Unmarshal(raw.JSON, &ev) 280 | case "/issues": 281 | err = json.Unmarshal(raw.JSON, &issue) 282 | } 283 | if err != nil { 284 | log.Fatalf("unmarshal: %v", err) 285 | } 286 | tm, err := time.Parse(time.RFC3339, raw.Time) 287 | if err != nil { 288 | log.Fatalf("parse time: %v", err) 289 | } 290 | 291 | items = append(items, &ghItem{Type: raw.Type, URL: raw.URL, Time: tm, Issue: issue, Event: ev, Comment: com}) 292 | } 293 | if len(items) > 0 { 294 | do(proj, lastIssue, items) 295 | } 296 | } 297 | 298 | func bodyText(who, verb, data string) []byte { 299 | body := "@" + who + " " + verb + ":\n" 300 | b := strings.Replace(data, "\r\n", "\n", -1) 301 | b = strings.TrimRight(b, "\n") 302 | b = strings.Replace(b, "\n", "\n\t", -1) 303 | body += "\n\t" + b 304 | return []byte(body) 305 | } 306 | -------------------------------------------------------------------------------- /internal/minutes/minutes.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Minutes is the program we use to post the proposal review minutes. 6 | // It is a demonstration of the use of the rsc.io/github API, but it is also not great code, 7 | // which is why it is buried in an internal directory. 8 | package main 9 | 10 | import ( 11 | "fmt" 12 | "io" 13 | "log" 14 | "os" 15 | "sort" 16 | "strconv" 17 | "strings" 18 | "time" 19 | 20 | "rsc.io/github" 21 | ) 22 | 23 | func main() { 24 | r, err := NewReporter() 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | 29 | data, err := io.ReadAll(os.Stdin) 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | r.Print(r.Update(string(data))) 34 | } 35 | 36 | type Reporter struct { 37 | Client *github.Client 38 | Proposals *github.Project 39 | Items map[int]*github.ProjectItem 40 | Labels map[string]*github.Label 41 | Backlog *github.Milestone 42 | } 43 | 44 | func NewReporter() (*Reporter, error) { 45 | c, err := github.Dial("") 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | r := &Reporter{Client: c} 51 | 52 | ps, err := r.Client.Projects("golang", "") 53 | if err != nil { 54 | return nil, err 55 | } 56 | for _, p := range ps { 57 | if p.Title == "Proposals" { 58 | r.Proposals = p 59 | break 60 | } 61 | } 62 | if r.Proposals == nil { 63 | return nil, fmt.Errorf("cannot find Proposals project") 64 | } 65 | 66 | labels, err := r.Client.SearchLabels("golang", "go", "") 67 | if err != nil { 68 | return nil, err 69 | } 70 | r.Labels = make(map[string]*github.Label) 71 | for _, label := range labels { 72 | r.Labels[label.Name] = label 73 | } 74 | 75 | milestones, err := r.Client.SearchMilestones("golang", "go", "Backlog") 76 | if err != nil { 77 | return nil, err 78 | } 79 | for _, m := range milestones { 80 | if m.Title == "Backlog" { 81 | r.Backlog = m 82 | break 83 | } 84 | } 85 | if r.Backlog == nil { 86 | return nil, fmt.Errorf("cannot find Backlog milestone") 87 | } 88 | 89 | items, err := r.Client.ProjectItems(r.Proposals) 90 | if err != nil { 91 | return nil, err 92 | } 93 | r.Items = make(map[int]*github.ProjectItem) 94 | for _, item := range items { 95 | if item.Issue == nil { 96 | log.Printf("warning: unexpected item with no issue") 97 | continue 98 | } 99 | r.Items[item.Issue.Number] = item 100 | } 101 | 102 | return r, nil 103 | } 104 | 105 | type Minutes struct { 106 | Who []string 107 | Events []*Event 108 | } 109 | 110 | type Event struct { 111 | Column string 112 | Issue string 113 | Title string 114 | Actions []string 115 | } 116 | 117 | func (r *Reporter) Update(text string) *Minutes { 118 | const prefix = "https://github.com/golang/go/issues/" 119 | 120 | m := new(Minutes) 121 | lines := strings.Split(text, "\n") 122 | for _, line := range lines { 123 | line = strings.TrimSpace(line) 124 | line = strings.ReplaceAll(line, "\t", " ") 125 | if line == "" { 126 | continue 127 | } 128 | if m.Who == nil { 129 | if strings.HasPrefix(line, prefix) { 130 | log.Printf("missing attendee list at start of input") 131 | break 132 | } 133 | who := strings.Fields(strings.ReplaceAll(line, ",", " ")) 134 | for i, w := range who { 135 | who[i] = gitWho(w) 136 | } 137 | m.Who = who 138 | continue 139 | } 140 | 141 | if !strings.HasPrefix(line, prefix) { 142 | log.Printf("unexpected line: %s", line) 143 | continue 144 | } 145 | 146 | url, actionstr, _ := strings.Cut(line, " ") 147 | issuenum := strings.TrimPrefix(url, prefix) 148 | url = "https://go.dev/issue/" + issuenum 149 | actionstr = strings.TrimSpace(actionstr) 150 | if actionstr == "" { 151 | log.Printf("line missing actions: %s", line) 152 | continue 153 | } 154 | 155 | actions := strings.Split(actionstr, ";") 156 | col := "Active" 157 | reason := "" 158 | for i, a := range actions { 159 | a = strings.TrimSpace(a) 160 | actions[i] = a 161 | switch a { 162 | case "accept": 163 | a = "accepted" 164 | case "decline": 165 | a = "declined" 166 | case "retract": 167 | a = "retracted" 168 | } 169 | 170 | switch a { 171 | case "likely accept": 172 | col = "Likely Accept" 173 | case "likely decline": 174 | col = "Likely Decline" 175 | case "accepted": 176 | col = "Accepted" 177 | case "declined": 178 | col = "Declined" 179 | case "retracted": 180 | col = "Declined" 181 | reason = "retracted" 182 | case "unhold": 183 | col = "Active" 184 | reason = "unhold" 185 | } 186 | if strings.HasPrefix(a, "duplicate") { 187 | col = "Declined" 188 | reason = "duplicate" 189 | } 190 | if strings.HasPrefix(a, "infeasible") { 191 | col = "Declined" 192 | reason = "infeasible" 193 | } 194 | if strings.HasPrefix(a, "closed") { 195 | col = "Declined" 196 | } 197 | if strings.HasPrefix(a, "hold") || a == "on hold" { 198 | col = "Hold" 199 | } 200 | if r := actionMap[a]; r != "" { 201 | actions[i] = r 202 | } 203 | if strings.HasPrefix(a, "removed") { 204 | col = "none" 205 | reason = "removed" 206 | } 207 | } 208 | 209 | id, err := strconv.Atoi(issuenum) 210 | if err != nil { 211 | log.Fatal(err) 212 | } 213 | item := r.Items[id] 214 | if item == nil { 215 | log.Printf("missing from proposal project: #%d", id) 216 | continue 217 | } 218 | issue := item.Issue 219 | status := item.FieldByName("Status") 220 | if status == nil { 221 | log.Printf("item missing status: #%d", id) 222 | continue 223 | } 224 | 225 | title := strings.TrimSpace(strings.TrimPrefix(issue.Title, "proposal:")) 226 | if status.Option.Name != col { 227 | msg := updateMsg(status.Option.Name, col, reason) 228 | if msg == "" { 229 | log.Fatalf("no update message for %s", col) 230 | } 231 | f := r.Proposals.FieldByName("Status") 232 | if col == "none" { 233 | if err := r.Client.DeleteProjectItem(r.Proposals, item); err != nil { 234 | log.Printf("%s: deleting proposal item: %v", url, err) 235 | continue 236 | } 237 | } else { 238 | o := f.OptionByName(col) 239 | if o == nil { 240 | log.Printf("%s: moving from %s to %s: no such status\n", url, status.Option.Name, col) 241 | continue 242 | } 243 | if err := r.Client.SetProjectItemFieldOption(r.Proposals, item, f, o); err != nil { 244 | log.Printf("%s: moving from %s to %s: %v\n", url, status.Option.Name, col, err) 245 | } 246 | } 247 | if err := r.Client.AddIssueComment(issue, msg); err != nil { 248 | log.Printf("%s: posting comment: %v", url, err) 249 | } 250 | } 251 | 252 | needLabel := func(name string) { 253 | if issue.LabelByName(name) == nil { 254 | lab := r.Labels[name] 255 | if lab == nil { 256 | log.Fatalf("%s: cannot find label %s", url, name) 257 | } 258 | if err := r.Client.AddIssueLabels(issue, lab); err != nil { 259 | log.Printf("%s: adding %s: %v", url, name, err) 260 | } 261 | } 262 | } 263 | 264 | dropLabel := func(name string) { 265 | if lab := issue.LabelByName(name); lab != nil { 266 | if err := r.Client.RemoveIssueLabels(issue, lab); err != nil { 267 | log.Printf("%s: removing %s: %v", url, name, err) 268 | } 269 | } 270 | } 271 | 272 | forceClose := func() { 273 | if !issue.Closed { 274 | if err := r.Client.CloseIssue(issue); err != nil { 275 | log.Printf("%s: closing issue: %v", url, err) 276 | } 277 | } 278 | } 279 | 280 | switch col { 281 | case "Accepted": 282 | if strings.HasPrefix(issue.Title, "proposal:") { 283 | if err := r.Client.RetitleIssue(issue, title); err != nil { 284 | log.Printf("%s: retitling: %v", url, err) 285 | } 286 | } 287 | needLabel("Proposal-Accepted") 288 | if issue.Milestone == nil || issue.Milestone.Title == "Proposal" { 289 | if err := r.Client.RemilestoneIssue(issue, r.Backlog); err != nil { 290 | log.Printf("%s: moving out of Proposal milestone: %v", url, err) 291 | } 292 | } 293 | case "Declined": 294 | dropLabel("Proposal-FinalCommentPeriod") 295 | forceClose() 296 | case "Likely Accept", "Likely Decline": 297 | needLabel("Proposal-FinalCommentPeriod") 298 | case "Hold": 299 | needLabel("Proposal-Hold") 300 | } 301 | m.Events = append(m.Events, &Event{Column: col, Issue: issuenum, Title: title, Actions: actions}) 302 | } 303 | 304 | sort.Slice(m.Events, func(i, j int) bool { 305 | return m.Events[i].Title < m.Events[j].Title 306 | }) 307 | return m 308 | } 309 | 310 | func (r *Reporter) Print(m *Minutes) { 311 | fmt.Printf("**%s / ", time.Now().Format("2006-01-02")) 312 | for i, who := range m.Who { 313 | if i > 0 { 314 | fmt.Printf(", ") 315 | } 316 | fmt.Printf("%s", who) 317 | } 318 | fmt.Printf("**\n\n") 319 | 320 | columns := []string{ 321 | "Accepted", 322 | "Declined", 323 | "Likely Accept", 324 | "Likely Decline", 325 | "Active", 326 | "Hold", 327 | "Other", 328 | } 329 | 330 | for _, col := range columns { 331 | n := 0 332 | for i, e := range m.Events { 333 | if e == nil || e.Column != col && col != "Other" { 334 | continue 335 | } 336 | if n == 0 { 337 | fmt.Printf("**%s**\n\n", col) 338 | } 339 | n++ 340 | fmt.Printf("- **%s** [#%s](https://go.dev/issue/%s)\n", markdownEscape(strings.TrimSpace(e.Title)), e.Issue, e.Issue) 341 | for _, a := range e.Actions { 342 | fmt.Printf(" - %s\n", a) 343 | } 344 | m.Events[i] = nil 345 | } 346 | if n == 0 && col != "Hold" && col != "Other" { 347 | fmt.Printf("**%s**\n\n", col) 348 | fmt.Printf("- none\n") 349 | } 350 | fmt.Printf("\n") 351 | } 352 | } 353 | 354 | var markdownEscaper = strings.NewReplacer( 355 | "_", `\_`, 356 | "*", `\*`, 357 | "`", "\\`", 358 | "[", `\[`, 359 | ) 360 | 361 | func markdownEscape(s string) string { 362 | return markdownEscaper.Replace(s) 363 | } 364 | -------------------------------------------------------------------------------- /internal/minutes2/minutes.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Minutes is the program we use to post the proposal review minutes. 6 | // It is a demonstration of the use of the rsc.io/github API, but it is also not great code, 7 | // which is why it is buried in an internal directory. 8 | package main 9 | 10 | import ( 11 | "encoding/csv" 12 | "encoding/json" 13 | "flag" 14 | "fmt" 15 | "log" 16 | "os" 17 | "sort" 18 | "strings" 19 | "time" 20 | 21 | "rsc.io/github" 22 | ) 23 | 24 | var docjson = flag.Bool("docjson", false, "print google doc info in json") 25 | var doccsv = flag.Bool("doccsv", false, "print google doc info in json") 26 | 27 | func main() { 28 | flag.Parse() 29 | 30 | doc := parseDoc() 31 | if *docjson { 32 | js, err := json.MarshalIndent(doc, "", "\t") 33 | if err != nil { 34 | log.Fatal(err) 35 | } 36 | os.Stdout.Write(append(js, '\n')) 37 | return 38 | } 39 | if *doccsv { 40 | var out [][]string 41 | for _, issue := range doc.Issues { 42 | out = append(out, []string{fmt.Sprint(issue.Number), issue.Minutes, issue.Title, issue.Details, issue.Comment, issue.Notes}) 43 | } 44 | w := csv.NewWriter(os.Stdout) 45 | w.WriteAll(out) 46 | w.Flush() 47 | return 48 | } 49 | 50 | r, err := NewReporter() 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | r.RetireOld() 55 | 56 | r.Print(r.Update(doc)) 57 | } 58 | 59 | type Reporter struct { 60 | Client *github.Client 61 | Proposals *github.Project 62 | Items map[int]*github.ProjectItem 63 | Labels map[string]*github.Label 64 | Backlog *github.Milestone 65 | } 66 | 67 | func NewReporter() (*Reporter, error) { 68 | c, err := github.Dial("") 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | r := &Reporter{Client: c} 74 | 75 | ps, err := r.Client.Projects("golang", "") 76 | if err != nil { 77 | return nil, err 78 | } 79 | for _, p := range ps { 80 | if p.Title == "Proposals" { 81 | r.Proposals = p 82 | break 83 | } 84 | } 85 | if r.Proposals == nil { 86 | return nil, fmt.Errorf("cannot find Proposals project") 87 | } 88 | 89 | labels, err := r.Client.SearchLabels("golang", "go", "") 90 | if err != nil { 91 | return nil, err 92 | } 93 | r.Labels = make(map[string]*github.Label) 94 | for _, label := range labels { 95 | r.Labels[label.Name] = label 96 | } 97 | 98 | milestones, err := r.Client.SearchMilestones("golang", "go", "Backlog") 99 | if err != nil { 100 | return nil, err 101 | } 102 | for _, m := range milestones { 103 | if m.Title == "Backlog" { 104 | r.Backlog = m 105 | break 106 | } 107 | } 108 | if r.Backlog == nil { 109 | return nil, fmt.Errorf("cannot find Backlog milestone") 110 | } 111 | 112 | items, err := r.Client.ProjectItems(r.Proposals) 113 | if err != nil { 114 | return nil, err 115 | } 116 | r.Items = make(map[int]*github.ProjectItem) 117 | for _, item := range items { 118 | if item.Issue == nil { 119 | log.Printf("warning: unexpected item with no issue") 120 | continue 121 | } 122 | r.Items[item.Issue.Number] = item 123 | } 124 | 125 | return r, nil 126 | } 127 | 128 | type Minutes struct { 129 | Who []string 130 | Events []*Event 131 | } 132 | 133 | type Event struct { 134 | Column string 135 | Issue string 136 | Title string 137 | Actions []string 138 | } 139 | 140 | const checkQuestion = "Have all remaining concerns about this proposal been addressed?" 141 | 142 | func (r *Reporter) Update(doc *Doc) *Minutes { 143 | const prefix = "https://github.com/golang/go/issues/" 144 | 145 | m := new(Minutes) 146 | 147 | // Attendees 148 | if len(doc.Who) == 0 { 149 | log.Fatalf("missing attendees") 150 | } 151 | m.Who = make([]string, len(doc.Who)) 152 | for i, w := range doc.Who { 153 | m.Who[i] = gitWho(w) 154 | } 155 | sort.Strings(m.Who) 156 | 157 | seen := make(map[int]bool) 158 | Issues: 159 | for _, di := range doc.Issues { 160 | item := r.Items[di.Number] 161 | if item == nil { 162 | log.Printf("missing from proposal project: #%d", di.Number) 163 | continue 164 | } 165 | seen[di.Number] = true 166 | issue := item.Issue 167 | status := item.FieldByName("Status") 168 | if status == nil { 169 | log.Printf("item missing status: #%d", di.Number) 170 | continue 171 | } 172 | 173 | title := strings.TrimSpace(strings.TrimPrefix(issue.Title, "proposal:")) 174 | if title != di.Title { 175 | log.Printf("#%d title mismatch:\nGH: %s\nDoc: %s", di.Number, issue.Title, di.Title) 176 | } 177 | 178 | url := "https://go.dev/issue/" + fmt.Sprint(di.Number) 179 | actions := strings.Split(di.Minutes, ";") 180 | col := "Active" 181 | reason := "" 182 | check := false 183 | for i, a := range actions { 184 | a = strings.TrimSpace(a) 185 | actions[i] = a 186 | switch a { 187 | case "TODO": 188 | log.Printf("%s: minutes TODO", url) 189 | continue Issues 190 | case "accept": 191 | a = "accepted" 192 | case "decline": 193 | a = "declined" 194 | case "retract": 195 | a = "retracted" 196 | case "declined as infeasible": 197 | a = "infeasible" 198 | case "check": 199 | check = true 200 | a = "comment" 201 | } 202 | 203 | switch a { 204 | case "likely accept": 205 | col = "Likely Accept" 206 | case "likely decline": 207 | col = "Likely Decline" 208 | case "accepted": 209 | col = "Accepted" 210 | case "declined": 211 | col = "Declined" 212 | case "retracted": 213 | col = "Declined" 214 | reason = "retracted" 215 | case "unhold": 216 | col = "Active" 217 | reason = "unhold" 218 | } 219 | if strings.HasPrefix(a, "declined") { 220 | col = "Declined" 221 | } 222 | if strings.HasPrefix(a, "duplicate") { 223 | col = "Declined" 224 | reason = "duplicate" 225 | } 226 | if strings.Contains(a, "infeasible") { 227 | col = "Declined" 228 | reason = "infeasible" 229 | } 230 | if a == "obsolete" || strings.Contains(a, "obsoleted") { 231 | col = "Declined" 232 | reason = "obsolete" 233 | } 234 | if strings.HasPrefix(a, "closed") { 235 | col = "Declined" 236 | } 237 | if strings.HasPrefix(a, "hold") || a == "on hold" { 238 | col = "Hold" 239 | } 240 | if r := actionMap[a]; r != "" { 241 | actions[i] = r 242 | } 243 | if strings.HasPrefix(a, "removed") { 244 | col = "none" 245 | reason = "removed" 246 | } 247 | } 248 | 249 | if check { 250 | comments, err := r.Client.IssueComments(issue) 251 | if err != nil { 252 | log.Printf("%s: cannot read issue comments\n", url) 253 | continue 254 | } 255 | for i := len(comments) - 1; i >= 0; i-- { 256 | c := comments[i] 257 | if time.Since(c.CreatedAt) < 5*24*time.Hour && strings.Contains(c.Body, checkQuestion) { 258 | log.Printf("%s: recently checked", url) 259 | continue Issues 260 | } 261 | } 262 | 263 | if di.Details == "" { 264 | log.Printf("%s: missing proposal details", url) 265 | continue Issues 266 | } 267 | msg := fmt.Sprintf("%s\n\n%s", checkQuestion, di.Details) 268 | // log.Fatalf("wouldpost %s\n%s", url, msg) 269 | if err := r.Client.AddIssueComment(issue, msg); err != nil { 270 | log.Printf("%s: posting comment: %v", url, err) 271 | } 272 | log.Printf("posted %s", url) 273 | } 274 | 275 | if status.Option.Name != col { 276 | msg := updateMsg(status.Option.Name, col, reason) 277 | if msg == "" { 278 | log.Fatalf("no update message for %s", col) 279 | } 280 | if col == "Likely Accept" || col == "Accepted" { 281 | if di.Details == "" { 282 | log.Printf("%s: missing proposal details", url) 283 | continue Issues 284 | } 285 | msg += "\n\n" + di.Details 286 | } 287 | f := r.Proposals.FieldByName("Status") 288 | if col == "none" { 289 | if err := r.Client.DeleteProjectItem(r.Proposals, item); err != nil { 290 | log.Printf("%s: deleting proposal item: %v", url, err) 291 | continue 292 | } 293 | } else { 294 | o := f.OptionByName(col) 295 | if o == nil { 296 | log.Printf("%s: moving from %s to %s: no such status\n", url, status.Option.Name, col) 297 | continue 298 | } 299 | if err := r.Client.SetProjectItemFieldOption(r.Proposals, item, f, o); err != nil { 300 | log.Printf("%s: moving from %s to %s: %v\n", url, status.Option.Name, col, err) 301 | } 302 | } 303 | if err := r.Client.AddIssueComment(issue, msg); err != nil { 304 | log.Printf("%s: posting comment: %v", url, err) 305 | } 306 | } 307 | 308 | needLabel := func(name string) { 309 | if issue.LabelByName(name) == nil { 310 | lab := r.Labels[name] 311 | if lab == nil { 312 | log.Fatalf("%s: cannot find label %s", url, name) 313 | } 314 | if err := r.Client.AddIssueLabels(issue, lab); err != nil { 315 | log.Printf("%s: adding %s: %v", url, name, err) 316 | } 317 | } 318 | } 319 | 320 | dropLabel := func(name string) { 321 | if lab := issue.LabelByName(name); lab != nil { 322 | if err := r.Client.RemoveIssueLabels(issue, lab); err != nil { 323 | log.Printf("%s: removing %s: %v", url, name, err) 324 | } 325 | } 326 | } 327 | 328 | setLabel := func(name string, val bool) { 329 | if val { 330 | needLabel(name) 331 | } else { 332 | dropLabel(name) 333 | } 334 | } 335 | 336 | forceClose := func() { 337 | if !issue.Closed { 338 | if err := r.Client.CloseIssue(issue); err != nil { 339 | log.Printf("%s: closing issue: %v", url, err) 340 | } 341 | } 342 | } 343 | 344 | if col == "Accepted" { 345 | if strings.HasPrefix(issue.Title, "proposal:") { 346 | if err := r.Client.RetitleIssue(issue, title); err != nil { 347 | log.Printf("%s: retitling: %v", url, err) 348 | } 349 | } 350 | if issue.Milestone == nil || issue.Milestone.Title == "Proposal" { 351 | if err := r.Client.RemilestoneIssue(issue, r.Backlog); err != nil { 352 | log.Printf("%s: moving out of Proposal milestone: %v", url, err) 353 | } 354 | } 355 | } 356 | if col == "Declined" { 357 | forceClose() 358 | } 359 | 360 | setLabel("Proposal-Accepted", col == "Accepted") 361 | setLabel("Proposal-FinalCommentPeriod", col == "Likely Accept" || col == "Likely Decline") 362 | setLabel("Proposal-Hold", col == "Hold") 363 | 364 | m.Events = append(m.Events, &Event{Column: col, Issue: fmt.Sprint(di.Number), Title: title, Actions: actions}) 365 | } 366 | 367 | for id, item := range r.Items { 368 | status := item.FieldByName("Status") 369 | if status != nil { 370 | switch status.Option.Name { 371 | case "Active", "Likely Accept", "Likely Decline": 372 | if !seen[id] { 373 | log.Printf("#%d: missing from doc", id) 374 | } 375 | } 376 | } 377 | } 378 | 379 | sort.Slice(m.Events, func(i, j int) bool { 380 | return m.Events[i].Title < m.Events[j].Title 381 | }) 382 | return m 383 | } 384 | 385 | func (r *Reporter) Print(m *Minutes) { 386 | fmt.Printf("**%s / ", time.Now().Format("2006-01-02")) 387 | for i, who := range m.Who { 388 | if i > 0 { 389 | fmt.Printf(", ") 390 | } 391 | fmt.Printf("%s", who) 392 | } 393 | fmt.Printf("**\n\n") 394 | 395 | disc, err := r.Client.Discussions("golang", "go") 396 | if err != nil { 397 | log.Fatal(err) 398 | } 399 | first := true 400 | for _, d := range disc { 401 | if d.Locked { 402 | continue 403 | } 404 | if first { 405 | fmt.Printf("**Discussions (not yet proposals)**\n\n") 406 | first = false 407 | } 408 | fmt.Printf("- **%s** [#%d](https://go.dev/issue/%d)\n", markdownEscape(strings.TrimSpace(d.Title)), d.Number, d.Number) 409 | } 410 | if !first { 411 | fmt.Printf("\n") 412 | } 413 | 414 | columns := []string{ 415 | "Accepted", 416 | "Declined", 417 | "Likely Accept", 418 | "Likely Decline", 419 | "Active", 420 | "Hold", 421 | "Other", 422 | } 423 | 424 | for _, col := range columns { 425 | n := 0 426 | for i, e := range m.Events { 427 | if e == nil || e.Column != col && col != "Other" { 428 | continue 429 | } 430 | if n == 0 { 431 | fmt.Printf("**%s**\n\n", col) 432 | } 433 | n++ 434 | fmt.Printf("- **%s** [#%s](https://go.dev/issue/%s)\n", markdownEscape(strings.TrimSpace(e.Title)), e.Issue, e.Issue) 435 | for _, a := range e.Actions { 436 | fmt.Printf(" - %s\n", a) 437 | } 438 | m.Events[i] = nil 439 | } 440 | if n == 0 && col != "Hold" && col != "Other" { 441 | fmt.Printf("**%s**\n\n", col) 442 | fmt.Printf("- none\n") 443 | } 444 | fmt.Printf("\n") 445 | } 446 | } 447 | 448 | var markdownEscaper = strings.NewReplacer( 449 | "_", `\_`, 450 | "*", `\*`, 451 | "`", "\\`", 452 | "[", `\[`, 453 | ) 454 | 455 | func markdownEscape(s string) string { 456 | return markdownEscaper.Replace(s) 457 | } 458 | 459 | func (r *Reporter) RetireOld() { 460 | for _, item := range r.Items { 461 | issue := item.Issue 462 | if issue.Closed && !issue.ClosedAt.IsZero() && time.Since(issue.ClosedAt) > 365*24*time.Hour { 463 | log.Printf("retire #%d", issue.Number) 464 | if err := r.Client.DeleteProjectItem(r.Proposals, item); err != nil { 465 | log.Printf("#%d: deleting proposal item: %v", issue.Number, err) 466 | } 467 | } 468 | } 469 | } 470 | -------------------------------------------------------------------------------- /internal/minutes3/go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14= 3 | cloud.google.com/go/auth v0.7.2 h1:uiha352VrCDMXg+yoBtaD0tUF4Kv9vrtrWPYXwutnDE= 4 | cloud.google.com/go/auth v0.7.2/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs= 5 | cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI= 6 | cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I= 7 | cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= 8 | cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= 9 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 10 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 11 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 12 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 16 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 17 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 18 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 19 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 20 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 21 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 22 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 23 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 24 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 25 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 26 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 27 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 28 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 29 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 30 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 31 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 32 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 33 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 34 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 35 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 36 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 37 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 38 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 39 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 40 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 41 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 42 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 43 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 44 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 45 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 46 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 47 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 48 | github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= 49 | github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= 50 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 51 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 52 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 53 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= 54 | github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= 55 | github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA= 56 | github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E= 57 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 58 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 59 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 60 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 61 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 62 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 63 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 64 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 65 | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= 66 | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= 67 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= 68 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= 69 | go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= 70 | go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= 71 | go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= 72 | go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= 73 | go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= 74 | go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= 75 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 76 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 77 | golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= 78 | golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= 79 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 80 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 81 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 82 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 83 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 84 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 85 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 86 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 87 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 88 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 89 | golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= 90 | golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= 91 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 92 | golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= 93 | golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 94 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 95 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 96 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 97 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 98 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 99 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 100 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 101 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 102 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 103 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 104 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 105 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 106 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 107 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 108 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 109 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 110 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 111 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 112 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 113 | google.golang.org/api v0.189.0 h1:equMo30LypAkdkLMBqfeIqtyAnlyig1JSZArl4XPwdI= 114 | google.golang.org/api v0.189.0/go.mod h1:FLWGJKb0hb+pU2j+rJqwbnsF+ym+fQs73rbJ+KAUgy8= 115 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 116 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 117 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 118 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 119 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 120 | google.golang.org/genproto v0.0.0-20240722135656-d784300faade h1:lKFsS7wpngDgSCeFn7MoLy+wBDQZ1UQIJD4UNM1Qvkg= 121 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade h1:oCRSWfwGXQsqlVdErcyTt4A93Y8fo0/9D4b1gnI++qo= 122 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= 123 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 124 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 125 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 126 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 127 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= 128 | google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= 129 | google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= 130 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 131 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 132 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 133 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 134 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 135 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 136 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 137 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 138 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 139 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 140 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 141 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 142 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 143 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 144 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 145 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 146 | -------------------------------------------------------------------------------- /internal/minutes3/minutes.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Minutes is the program we use to post the proposal review minutes. 6 | // It is a demonstration of the use of the rsc.io/github API, but it is also not great code, 7 | // which is why it is buried in an internal directory. 8 | package main 9 | 10 | import ( 11 | "bytes" 12 | "encoding/csv" 13 | "encoding/json" 14 | "flag" 15 | "fmt" 16 | "log" 17 | "os" 18 | "sort" 19 | "strings" 20 | "time" 21 | 22 | "rsc.io/github" 23 | ) 24 | 25 | var docjson = flag.Bool("docjson", false, "print google doc info in json") 26 | var doccsv = flag.Bool("doccsv", false, "print google doc info in json") 27 | 28 | var failure = false 29 | 30 | func main() { 31 | log.SetPrefix("minutes3: ") 32 | log.SetFlags(0) 33 | 34 | flag.Parse() 35 | doc := parseDoc() 36 | if *docjson { 37 | js, err := json.MarshalIndent(doc, "", "\t") 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | os.Stdout.Write(append(js, '\n')) 42 | return 43 | } 44 | if *doccsv { 45 | var out [][]string 46 | for _, issue := range doc.Issues { 47 | out = append(out, []string{fmt.Sprint(issue.Number), issue.Minutes, issue.Title, issue.Details, issue.Comment, issue.Notes}) 48 | } 49 | w := csv.NewWriter(os.Stdout) 50 | w.WriteAll(out) 51 | w.Flush() 52 | return 53 | } 54 | 55 | r, err := NewReporter() 56 | if err != nil { 57 | log.Fatal(err) 58 | } 59 | r.RetireOld() 60 | 61 | minutes := r.Update(doc) 62 | if failure { 63 | return 64 | } 65 | fmt.Printf("TO POST TO https://go.dev/s/proposal-minutes:\n\n") 66 | r.Print(minutes) 67 | } 68 | 69 | type Reporter struct { 70 | Client *github.Client 71 | Proposals *github.Project 72 | Items map[int]*github.ProjectItem 73 | Labels map[string]*github.Label 74 | Backlog *github.Milestone 75 | } 76 | 77 | func NewReporter() (*Reporter, error) { 78 | c, err := github.Dial("") 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | r := &Reporter{Client: c} 84 | 85 | ps, err := r.Client.Projects("golang", "") 86 | if err != nil { 87 | return nil, err 88 | } 89 | for _, p := range ps { 90 | if p.Title == "Proposals" { 91 | r.Proposals = p 92 | break 93 | } 94 | } 95 | if r.Proposals == nil { 96 | return nil, fmt.Errorf("cannot find Proposals project") 97 | } 98 | 99 | labels, err := r.Client.SearchLabels("golang", "go", "") 100 | if err != nil { 101 | return nil, err 102 | } 103 | r.Labels = make(map[string]*github.Label) 104 | for _, label := range labels { 105 | r.Labels[label.Name] = label 106 | } 107 | 108 | milestones, err := r.Client.SearchMilestones("golang", "go", "Backlog") 109 | if err != nil { 110 | return nil, err 111 | } 112 | for _, m := range milestones { 113 | if m.Title == "Backlog" { 114 | r.Backlog = m 115 | break 116 | } 117 | } 118 | if r.Backlog == nil { 119 | return nil, fmt.Errorf("cannot find Backlog milestone") 120 | } 121 | 122 | items, err := r.Client.ProjectItems(r.Proposals) 123 | if err != nil { 124 | return nil, err 125 | } 126 | r.Items = make(map[int]*github.ProjectItem) 127 | for _, item := range items { 128 | if item.Issue == nil { 129 | log.Printf("unexpected project item with no issue") 130 | failure = true 131 | continue 132 | } 133 | r.Items[item.Issue.Number] = item 134 | } 135 | 136 | return r, nil 137 | } 138 | 139 | type Minutes struct { 140 | Date time.Time 141 | Who []string 142 | Events []*Event 143 | } 144 | 145 | type Event struct { 146 | Column string 147 | Issue string 148 | Title string 149 | Actions []string 150 | } 151 | 152 | const checkQuestion = "Have all remaining concerns about this proposal been addressed?" 153 | 154 | func (r *Reporter) Update(doc *Doc) *Minutes { 155 | const prefix = "https://github.com/golang/go/issues/" 156 | 157 | m := new(Minutes) 158 | m.Date = doc.Date 159 | 160 | // Attendees 161 | if len(doc.Who) == 0 { 162 | log.Fatalf("missing attendees") 163 | } 164 | m.Who = make([]string, len(doc.Who)) 165 | for i, w := range doc.Who { 166 | m.Who[i] = gitWho(w) 167 | } 168 | sort.Strings(m.Who) 169 | 170 | seen := make(map[int]bool) 171 | Issues: 172 | for _, di := range doc.Issues { 173 | item := r.Items[di.Number] 174 | if item == nil { 175 | log.Printf("missing from proposal project: #%d", di.Number) 176 | failure = true 177 | continue 178 | } 179 | seen[di.Number] = true 180 | issue := item.Issue 181 | status := item.FieldByName("Status") 182 | if status == nil { 183 | log.Printf("item missing status: #%d", di.Number) 184 | failure = true 185 | continue 186 | } 187 | 188 | title := strings.TrimSpace(strings.TrimPrefix(issue.Title, "proposal:")) 189 | if title != di.Title { 190 | log.Printf("#%d title mismatch:\nGH: %s\nDoc: %s", di.Number, issue.Title, di.Title) 191 | failure = true 192 | } 193 | 194 | url := "https://go.dev/issue/" + fmt.Sprint(di.Number) 195 | actions := strings.Split(di.Minutes, ";") 196 | if len(actions) == 1 && actions[0] == "" { 197 | actions = nil 198 | } 199 | if len(actions) == 0 { 200 | log.Printf("#%d missing action") 201 | failure = true 202 | } 203 | col := "Active" 204 | reason := "" 205 | check := false 206 | for i, a := range actions { 207 | a = strings.TrimSpace(a) 208 | actions[i] = a 209 | switch a { 210 | case "TODO": 211 | log.Printf("%s: minutes TODO", url) 212 | failure = true 213 | continue Issues 214 | case "accept": 215 | a = "accepted" 216 | case "decline": 217 | a = "declined" 218 | case "retract": 219 | a = "retracted" 220 | case "declined as infeasible": 221 | a = "infeasible" 222 | case "check": 223 | check = true 224 | a = "comment" 225 | } 226 | 227 | switch a { 228 | case "likely accept": 229 | col = "Likely Accept" 230 | case "likely decline": 231 | col = "Likely Decline" 232 | case "accepted": 233 | col = "Accepted" 234 | case "declined": 235 | col = "Declined" 236 | case "retracted": 237 | col = "Declined" 238 | reason = "retracted" 239 | case "unhold": 240 | col = "Active" 241 | reason = "unhold" 242 | } 243 | if strings.HasPrefix(a, "declined") { 244 | col = "Declined" 245 | } 246 | if strings.HasPrefix(a, "duplicate") { 247 | col = "Declined" 248 | reason = "duplicate" 249 | } 250 | if strings.Contains(a, "infeasible") { 251 | col = "Declined" 252 | reason = "infeasible" 253 | } 254 | if a == "obsolete" || strings.Contains(a, "obsoleted") { 255 | col = "Declined" 256 | reason = "obsolete" 257 | } 258 | if strings.HasPrefix(a, "closed") { 259 | col = "Declined" 260 | } 261 | if strings.HasPrefix(a, "hold") || a == "on hold" { 262 | col = "Hold" 263 | } 264 | if r := actionMap[a]; r != "" { 265 | actions[i] = r 266 | } 267 | if strings.HasPrefix(a, "removed") { 268 | col = "none" 269 | reason = "removed" 270 | } 271 | } 272 | 273 | if check { 274 | comments, err := r.Client.IssueComments(issue) 275 | if err != nil { 276 | log.Printf("%s: cannot read issue comments\n", url) 277 | failure = true 278 | continue 279 | } 280 | for i := len(comments) - 1; i >= 0; i-- { 281 | c := comments[i] 282 | if time.Since(c.CreatedAt) < 5*24*time.Hour && strings.Contains(c.Body, checkQuestion) { 283 | log.Printf("%s: recently checked", url) 284 | continue Issues 285 | } 286 | } 287 | 288 | if di.Details == "" { 289 | log.Printf("%s: missing proposal details", url) 290 | failure = true 291 | continue Issues 292 | } 293 | msg := fmt.Sprintf("%s\n\n%s", checkQuestion, di.Details) 294 | // log.Fatalf("wouldpost %s\n%s", url, msg) 295 | if err := r.Client.AddIssueComment(issue, msg); err != nil { 296 | log.Printf("%s: posting comment: %v", url, err) 297 | failure = true 298 | } 299 | log.Printf("posted %s", url) 300 | } 301 | 302 | if status.Option.Name != col { 303 | msg := updateMsg(status.Option.Name, col, reason) 304 | if msg == "" { 305 | log.Fatalf("no update message for %s", col) 306 | } 307 | if col == "Likely Accept" || col == "Accepted" { 308 | if di.Details == "" { 309 | log.Printf("%s: missing proposal details", url) 310 | failure = true 311 | continue Issues 312 | } 313 | msg += "\n\n" + di.Details 314 | } 315 | f := r.Proposals.FieldByName("Status") 316 | if col == "none" { 317 | if err := r.Client.DeleteProjectItem(r.Proposals, item); err != nil { 318 | log.Printf("%s: deleting proposal item: %v", url, err) 319 | failure = true 320 | continue 321 | } 322 | } else { 323 | o := f.OptionByName(col) 324 | if o == nil { 325 | log.Printf("%s: moving from %s to %s: no such status\n", url, status.Option.Name, col) 326 | failure = true 327 | continue 328 | } 329 | if err := r.Client.SetProjectItemFieldOption(r.Proposals, item, f, o); err != nil { 330 | log.Printf("%s: moving from %s to %s: %v\n", url, status.Option.Name, col, err) 331 | failure = true 332 | } 333 | } 334 | if err := r.Client.AddIssueComment(issue, msg); err != nil { 335 | log.Printf("%s: posting comment: %v", url, err) 336 | failure = true 337 | } 338 | } 339 | 340 | needLabel := func(name string) { 341 | if issue.LabelByName(name) == nil { 342 | lab := r.Labels[name] 343 | if lab == nil { 344 | log.Fatalf("%s: cannot find label %s", url, name) 345 | } 346 | if err := r.Client.AddIssueLabels(issue, lab); err != nil { 347 | log.Printf("%s: adding %s: %v", url, name, err) 348 | failure = true 349 | } 350 | } 351 | } 352 | 353 | dropLabel := func(name string) { 354 | if lab := issue.LabelByName(name); lab != nil { 355 | if err := r.Client.RemoveIssueLabels(issue, lab); err != nil { 356 | log.Printf("%s: removing %s: %v", url, name, err) 357 | failure = true 358 | } 359 | } 360 | } 361 | 362 | setLabel := func(name string, val bool) { 363 | if val { 364 | needLabel(name) 365 | } else { 366 | dropLabel(name) 367 | } 368 | } 369 | 370 | forceClose := func() { 371 | if !issue.Closed { 372 | if err := r.Client.CloseIssue(issue); err != nil { 373 | log.Printf("%s: closing issue: %v", url, err) 374 | failure = true 375 | } 376 | } 377 | } 378 | 379 | if col == "Accepted" { 380 | if strings.HasPrefix(issue.Title, "proposal:") { 381 | if err := r.Client.RetitleIssue(issue, title); err != nil { 382 | log.Printf("%s: retitling: %v", url, err) 383 | failure = true 384 | } 385 | } 386 | if issue.Milestone == nil || issue.Milestone.Title == "Proposal" { 387 | if err := r.Client.RemilestoneIssue(issue, r.Backlog); err != nil { 388 | log.Printf("%s: moving out of Proposal milestone: %v", url, err) 389 | failure = true 390 | } 391 | } 392 | } 393 | if col == "Declined" { 394 | forceClose() 395 | } 396 | 397 | setLabel("Proposal-Accepted", col == "Accepted") 398 | setLabel("Proposal-FinalCommentPeriod", col == "Likely Accept" || col == "Likely Decline") 399 | setLabel("Proposal-Hold", col == "Hold") 400 | 401 | m.Events = append(m.Events, &Event{Column: col, Issue: fmt.Sprint(di.Number), Title: title, Actions: actions}) 402 | } 403 | 404 | for id, item := range r.Items { 405 | status := item.FieldByName("Status") 406 | if status != nil { 407 | switch status.Option.Name { 408 | case "Active", "Likely Accept", "Likely Decline": 409 | if !seen[id] { 410 | log.Printf("#%d: missing from doc", id) 411 | failure = true 412 | } 413 | } 414 | } 415 | } 416 | 417 | sort.Slice(m.Events, func(i, j int) bool { 418 | return m.Events[i].Title < m.Events[j].Title 419 | }) 420 | return m 421 | } 422 | 423 | func (r *Reporter) Print(m *Minutes) { 424 | var buf bytes.Buffer 425 | 426 | fmt.Fprintf(&buf, "**%s / ", m.Date.Format("2006-01-02")) 427 | for i, who := range m.Who { 428 | if i > 0 { 429 | fmt.Fprintf(&buf, ", ") 430 | } 431 | fmt.Fprintf(&buf, "%s", who) 432 | } 433 | fmt.Fprintf(&buf, "**\n\n") 434 | 435 | disc, err := r.Client.Discussions("golang", "go") 436 | if err != nil { 437 | log.Fatal(err) 438 | } 439 | first := true 440 | for _, d := range disc { 441 | if d.Locked { 442 | continue 443 | } 444 | if first { 445 | fmt.Fprintf(&buf, "**Discussions (not yet proposals)**\n\n") 446 | first = false 447 | } 448 | fmt.Fprintf(&buf, "- **%s** [#%d](https://go.dev/issue/%d)\n", markdownEscape(strings.TrimSpace(d.Title)), d.Number, d.Number) 449 | } 450 | if !first { 451 | fmt.Fprintf(&buf, "\n") 452 | } 453 | 454 | columns := []string{ 455 | "Accepted", 456 | "Declined", 457 | "Likely Accept", 458 | "Likely Decline", 459 | "Active", 460 | "Hold", 461 | "Other", 462 | } 463 | 464 | for _, col := range columns { 465 | n := 0 466 | for i, e := range m.Events { 467 | if e == nil || e.Column != col && col != "Other" { 468 | continue 469 | } 470 | if n == 0 { 471 | fmt.Fprintf(&buf, "**%s**\n\n", col) 472 | } 473 | n++ 474 | fmt.Fprintf(&buf, "- **%s** [#%s](https://go.dev/issue/%s)\n", markdownEscape(strings.TrimSpace(e.Title)), e.Issue, e.Issue) 475 | for _, a := range e.Actions { 476 | if a == "" { 477 | // If we print an empty string, the - by itself will turn 478 | // the previous line into a markdown heading! 479 | // Also everything should have an action. 480 | log.Fatalf("#%s: missing action", e.Issue) 481 | } 482 | fmt.Fprintf(&buf, " - %s\n", a) 483 | } 484 | m.Events[i] = nil 485 | } 486 | if n == 0 && col != "Hold" && col != "Other" { 487 | fmt.Fprintf(&buf, "**%s**\n\n", col) 488 | fmt.Fprintf(&buf, "- none\n") 489 | } 490 | fmt.Fprintf(&buf, "\n") 491 | } 492 | 493 | os.Stdout.Write(buf.Bytes()) 494 | } 495 | 496 | var markdownEscaper = strings.NewReplacer( 497 | "_", `\_`, 498 | "*", `\*`, 499 | "`", "\\`", 500 | "[", `\[`, 501 | ) 502 | 503 | func markdownEscape(s string) string { 504 | return markdownEscaper.Replace(s) 505 | } 506 | 507 | func (r *Reporter) RetireOld() { 508 | for _, item := range r.Items { 509 | issue := item.Issue 510 | if issue.Closed && !issue.ClosedAt.IsZero() && time.Since(issue.ClosedAt) > 365*24*time.Hour { 511 | log.Printf("retire #%d", issue.Number) 512 | if err := r.Client.DeleteProjectItem(r.Proposals, item); err != nil { 513 | log.Printf("#%d: deleting proposal item: %v", issue.Number, err) 514 | } 515 | } 516 | } 517 | } 518 | -------------------------------------------------------------------------------- /project.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package github 6 | 7 | import ( 8 | "fmt" 9 | "time" 10 | 11 | "rsc.io/github/schema" 12 | ) 13 | 14 | func (c *Client) Projects(org, query string) ([]*Project, error) { 15 | commonField := ` 16 | createdAt 17 | dataType 18 | id 19 | name 20 | updatedAt 21 | ` 22 | graphql := ` 23 | query($Org: String!, $Query: String, $Cursor: String) { 24 | organization(login: $Org) { 25 | projectsV2(first: 100, query: $Query, after: $Cursor) { 26 | pageInfo { 27 | hasNextPage 28 | endCursor 29 | } 30 | totalCount 31 | nodes { 32 | closed 33 | closedAt 34 | createdAt 35 | updatedAt 36 | id 37 | number 38 | title 39 | url 40 | fields(first: 100) { 41 | pageInfo { 42 | hasNextPage 43 | endCursor 44 | } 45 | totalCount 46 | nodes { 47 | __typename 48 | ... on ProjectV2Field { 49 | ` + commonField + ` 50 | } 51 | ... on ProjectV2IterationField { 52 | ` + commonField + ` 53 | configuration { 54 | completedIterations { 55 | duration 56 | id 57 | startDate 58 | title 59 | titleHTML 60 | } 61 | iterations { 62 | duration 63 | id 64 | startDate 65 | title 66 | titleHTML 67 | } 68 | duration 69 | startDay 70 | } 71 | } 72 | ... on ProjectV2SingleSelectField { 73 | ` + commonField + ` 74 | options { 75 | id 76 | name 77 | nameHTML 78 | } 79 | } 80 | } 81 | } 82 | } 83 | } 84 | } 85 | } 86 | ` 87 | 88 | vars := Vars{"Org": org} 89 | if query != "" { 90 | vars["Query"] = query 91 | } 92 | return collect(c, graphql, vars, 93 | toProject(org), 94 | func(q *schema.Query) pager[*schema.ProjectV2] { return q.Organization.ProjectsV2 }, 95 | ) 96 | } 97 | 98 | const projectItemFields = ` 99 | databaseId 100 | fieldValues(first: 100) { 101 | pageInfo { 102 | hasNextPage 103 | endCursor 104 | } 105 | totalCount 106 | nodes { 107 | __typename 108 | ... on ProjectV2ItemFieldDateValue { 109 | createdAt databaseId id updatedAt 110 | date 111 | field { __typename ... on ProjectV2Field { databaseId id name } } 112 | } 113 | ... on ProjectV2ItemFieldIterationValue { 114 | createdAt databaseId id updatedAt 115 | field { __typename ... on ProjectV2IterationField { databaseId id name } } 116 | } 117 | ... on ProjectV2ItemFieldLabelValue { 118 | field { __typename ... on ProjectV2Field { databaseId id name } } 119 | } 120 | ... on ProjectV2ItemFieldMilestoneValue { 121 | field { __typename ... on ProjectV2Field { databaseId id name } } 122 | } 123 | ... on ProjectV2ItemFieldNumberValue { 124 | createdAt databaseId id updatedAt 125 | number 126 | field { __typename ... on ProjectV2Field { databaseId id name } } 127 | } 128 | ... on ProjectV2ItemFieldPullRequestValue { 129 | field { __typename ... on ProjectV2Field { databaseId id name } } 130 | } 131 | ... on ProjectV2ItemFieldRepositoryValue { 132 | field { __typename ... on ProjectV2Field { databaseId id name } } 133 | } 134 | ... on ProjectV2ItemFieldReviewerValue { 135 | field { __typename ... on ProjectV2Field { databaseId id name } } 136 | } 137 | ... on ProjectV2ItemFieldSingleSelectValue { 138 | createdAt databaseId id updatedAt 139 | name nameHTML optionId 140 | field { __typename ... on ProjectV2SingleSelectField { databaseId id name } } 141 | } 142 | ... on ProjectV2ItemFieldTextValue { 143 | createdAt databaseId id updatedAt 144 | text 145 | field { __typename ... on ProjectV2Field { databaseId id name } } 146 | } 147 | ... on ProjectV2ItemFieldUserValue { 148 | field { __typename ... on ProjectV2Field { databaseId id name } } 149 | } 150 | } 151 | } 152 | id 153 | isArchived 154 | type 155 | updatedAt 156 | createdAt 157 | content { 158 | __typename 159 | ... on Issue { 160 | ` + issueFields + ` 161 | } 162 | } 163 | ` 164 | 165 | func (c *Client) ProjectItems(p *Project) ([]*ProjectItem, error) { 166 | graphql := ` 167 | query($Org: String!, $ProjectNumber: Int!, $Cursor: String) { 168 | organization(login: $Org) { 169 | projectV2(number: $ProjectNumber) { 170 | items(first: 100, after: $Cursor) { 171 | pageInfo { 172 | hasNextPage 173 | endCursor 174 | } 175 | totalCount 176 | nodes { 177 | ` + projectItemFields + ` 178 | } 179 | } 180 | } 181 | } 182 | } 183 | ` 184 | 185 | vars := Vars{"Org": p.Org, "ProjectNumber": p.Number} 186 | return collect(c, graphql, vars, 187 | p.toProjectItem, 188 | func(q *schema.Query) pager[*schema.ProjectV2Item] { return q.Organization.ProjectV2.Items }, 189 | ) 190 | } 191 | 192 | type Project struct { 193 | ID string 194 | Closed bool 195 | ClosedAt time.Time 196 | CreatedAt time.Time 197 | UpdatedAt time.Time 198 | Fields []*ProjectField 199 | Number int 200 | Title string 201 | URL string 202 | Org string 203 | } 204 | 205 | func (p *Project) FieldByName(name string) *ProjectField { 206 | for _, f := range p.Fields { 207 | if f.Name == name { 208 | return f 209 | } 210 | } 211 | return nil 212 | } 213 | 214 | func toProject(org string) func(*schema.ProjectV2) *Project { 215 | return func(s *schema.ProjectV2) *Project { 216 | // TODO: Check p.Fields.PageInfo.HasNextPage. 217 | return &Project{ 218 | ID: string(s.Id), 219 | Closed: s.Closed, 220 | ClosedAt: toTime(s.ClosedAt), 221 | CreatedAt: toTime(s.CreatedAt), 222 | UpdatedAt: toTime(s.UpdatedAt), 223 | Fields: apply(toProjectField, s.Fields.Nodes), 224 | Number: s.Number, 225 | Title: s.Title, 226 | URL: string(s.Url), 227 | Org: org, 228 | } 229 | } 230 | } 231 | 232 | type ProjectField struct { 233 | Kind string // "field", "iteration", "select" 234 | CreatedAt time.Time 235 | UpdatedAt time.Time 236 | DataType schema.ProjectV2FieldType // TODO 237 | DatabaseID int 238 | ID schema.ID 239 | Name string 240 | Iterations *ProjectIterations 241 | Options []*ProjectFieldOption 242 | } 243 | 244 | func (f *ProjectField) OptionByName(name string) *ProjectFieldOption { 245 | for _, o := range f.Options { 246 | if o.Name == name { 247 | return o 248 | } 249 | } 250 | return nil 251 | } 252 | 253 | func toProjectField(su schema.ProjectV2FieldConfiguration) *ProjectField { 254 | s, _ := su.Interface.(schema.ProjectV2FieldCommon_Interface) 255 | f := &ProjectField{ 256 | CreatedAt: toTime(s.GetCreatedAt()), 257 | UpdatedAt: toTime(s.GetUpdatedAt()), 258 | DatabaseID: s.GetDatabaseId(), 259 | ID: s.GetId(), 260 | Name: s.GetName(), 261 | } 262 | switch s := s.(type) { 263 | case *schema.ProjectV2Field: 264 | f.Kind = "field" 265 | case *schema.ProjectV2IterationField: 266 | f.Kind = "iteration" 267 | f.Iterations = toProjectIterations(s.Configuration) 268 | case *schema.ProjectV2SingleSelectField: 269 | f.Kind = "select" 270 | f.Options = apply(toProjectFieldOption, s.Options) 271 | } 272 | return f 273 | } 274 | 275 | type ProjectIterations struct { 276 | Completed []*ProjectIteration 277 | Active []*ProjectIteration 278 | Days int 279 | StartDay time.Weekday 280 | } 281 | 282 | func toProjectIterations(s *schema.ProjectV2IterationFieldConfiguration) *ProjectIterations { 283 | return &ProjectIterations{ 284 | Completed: apply(toProjectIteration, s.CompletedIterations), 285 | Active: apply(toProjectIteration, s.Iterations), 286 | StartDay: time.Weekday(s.StartDay), 287 | Days: s.Duration, 288 | } 289 | } 290 | 291 | type ProjectIteration struct { 292 | Days int 293 | ID string 294 | Start time.Time 295 | Title string 296 | TitleHTML string 297 | } 298 | 299 | func toProjectIteration(s *schema.ProjectV2IterationFieldIteration) *ProjectIteration { 300 | return &ProjectIteration{ 301 | Days: s.Duration, 302 | ID: s.Id, 303 | Start: toDate(s.StartDate), 304 | Title: s.Title, 305 | TitleHTML: s.TitleHTML, 306 | } 307 | } 308 | 309 | type ProjectFieldOption struct { 310 | ID string 311 | Name string 312 | NameHTML string 313 | } 314 | 315 | func (o *ProjectFieldOption) String() string { 316 | return fmt.Sprintf("%+v", *o) 317 | } 318 | 319 | func toProjectFieldOption(s *schema.ProjectV2SingleSelectFieldOption) *ProjectFieldOption { 320 | return &ProjectFieldOption{ 321 | ID: s.Id, 322 | Name: s.Name, 323 | NameHTML: s.NameHTML, 324 | } 325 | } 326 | 327 | type ProjectItem struct { 328 | CreatedAt time.Time 329 | DatabaseID int 330 | ID schema.ID 331 | IsArchived bool 332 | Type schema.ProjectV2ItemType 333 | UpdatedAt time.Time 334 | Fields []*ProjectFieldValue 335 | Issue *Issue 336 | } 337 | 338 | func (it *ProjectItem) FieldByName(name string) *ProjectFieldValue { 339 | for _, f := range it.Fields { 340 | if f.Field == name { 341 | return f 342 | } 343 | } 344 | return nil 345 | } 346 | 347 | func (p *Project) toProjectItem(s *schema.ProjectV2Item) *ProjectItem { 348 | // TODO: Check p.Fields.PageInfo.HasNextPage. 349 | it := &ProjectItem{ 350 | CreatedAt: toTime(s.CreatedAt), 351 | DatabaseID: s.DatabaseId, 352 | ID: s.Id, 353 | IsArchived: s.IsArchived, 354 | Type: s.Type, 355 | UpdatedAt: toTime(s.UpdatedAt), 356 | Fields: apply(p.toProjectFieldValue, s.FieldValues.Nodes), 357 | // TODO Issue 358 | } 359 | if si, ok := s.Content.Interface.(*schema.Issue); ok { 360 | it.Issue = toIssue(si) 361 | } 362 | return it 363 | } 364 | 365 | type ProjectFieldValue struct { 366 | CreatedAt time.Time 367 | UpdatedAt time.Time 368 | Kind string 369 | ID string 370 | DatabaseID int 371 | Field string 372 | Option *ProjectFieldOption 373 | Date time.Time 374 | Text string 375 | } 376 | 377 | func (v *ProjectFieldValue) String() string { 378 | switch v.Kind { 379 | case "date": 380 | return fmt.Sprintf("%s:%v", v.Field, v.Date.Format("2006-01-02")) 381 | case "text": 382 | return fmt.Sprintf("%s:%q", v.Field, v.Text) 383 | case "select": 384 | return fmt.Sprintf("%s:%q", v.Field, v.Option) 385 | } 386 | return fmt.Sprintf("%s:???", v.Field) 387 | } 388 | 389 | func (p *Project) optionByID(id string) *ProjectFieldOption { 390 | for _, f := range p.Fields { 391 | for _, o := range f.Options { 392 | if o.ID == id { 393 | return o 394 | } 395 | } 396 | } 397 | return nil 398 | } 399 | 400 | func (p *Project) toProjectFieldValue(s schema.ProjectV2ItemFieldValue) *ProjectFieldValue { 401 | switch sv := s.Interface.(type) { 402 | case *schema.ProjectV2ItemFieldDateValue: 403 | return &ProjectFieldValue{ 404 | Kind: "date", 405 | CreatedAt: toTime(sv.CreatedAt), 406 | DatabaseID: sv.DatabaseId, 407 | Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), 408 | ID: string(sv.Id), 409 | UpdatedAt: toTime(sv.UpdatedAt), 410 | Date: toDate(sv.Date), 411 | } 412 | case *schema.ProjectV2ItemFieldIterationValue: 413 | return &ProjectFieldValue{ 414 | Kind: "iteration", 415 | CreatedAt: toTime(sv.CreatedAt), 416 | DatabaseID: sv.DatabaseId, 417 | Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), 418 | ID: string(sv.Id), 419 | UpdatedAt: toTime(sv.UpdatedAt), 420 | } 421 | case *schema.ProjectV2ItemFieldLabelValue: 422 | return &ProjectFieldValue{ 423 | Kind: "label", 424 | Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), 425 | } 426 | case *schema.ProjectV2ItemFieldMilestoneValue: 427 | return &ProjectFieldValue{ 428 | Kind: "milestone", 429 | Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), 430 | } 431 | case *schema.ProjectV2ItemFieldNumberValue: 432 | return &ProjectFieldValue{ 433 | Kind: "number", 434 | CreatedAt: toTime(sv.CreatedAt), 435 | DatabaseID: sv.DatabaseId, 436 | Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), 437 | ID: string(sv.Id), 438 | UpdatedAt: toTime(sv.UpdatedAt), 439 | } 440 | case *schema.ProjectV2ItemFieldPullRequestValue: 441 | return &ProjectFieldValue{ 442 | Kind: "pr", 443 | Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), 444 | } 445 | case *schema.ProjectV2ItemFieldRepositoryValue: 446 | return &ProjectFieldValue{ 447 | Kind: "repo", 448 | Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), 449 | } 450 | case *schema.ProjectV2ItemFieldReviewerValue: 451 | return &ProjectFieldValue{ 452 | Kind: "reviewer", 453 | Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), 454 | } 455 | case *schema.ProjectV2ItemFieldSingleSelectValue: 456 | return &ProjectFieldValue{ 457 | Kind: "select", 458 | CreatedAt: toTime(sv.CreatedAt), 459 | DatabaseID: sv.DatabaseId, 460 | Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), 461 | ID: string(sv.Id), 462 | UpdatedAt: toTime(sv.UpdatedAt), 463 | 464 | Option: p.optionByID(sv.OptionId), 465 | } 466 | case *schema.ProjectV2ItemFieldTextValue: 467 | return &ProjectFieldValue{ 468 | Kind: "text", 469 | CreatedAt: toTime(sv.CreatedAt), 470 | DatabaseID: sv.DatabaseId, 471 | Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), 472 | ID: string(sv.Id), 473 | UpdatedAt: toTime(sv.UpdatedAt), 474 | Text: sv.Text, 475 | } 476 | case *schema.ProjectV2ItemFieldUserValue: 477 | return &ProjectFieldValue{ 478 | Kind: "user", 479 | Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), 480 | } 481 | } 482 | return &ProjectFieldValue{} 483 | } 484 | -------------------------------------------------------------------------------- /internal/minutes2/go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go/auth v0.5.1 h1:0QNO7VThG54LUzKiQxv8C6x1YX7lUrzlAa1nVLF8CIw= 3 | cloud.google.com/go/auth v0.5.1/go.mod h1:vbZT8GjzDf3AVqCcQmqeeM32U9HBFc32vVVAbwDsa6s= 4 | cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= 5 | cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= 6 | cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= 7 | cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 8 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 9 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 10 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 11 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 12 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 16 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 17 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 18 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 19 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 20 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 21 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 22 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 23 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 24 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 25 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 26 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 27 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 28 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 29 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 30 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 31 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 32 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 33 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 34 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 35 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 36 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 37 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 38 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 39 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 40 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 41 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 42 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 43 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 44 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 45 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 46 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 47 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 48 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 49 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 50 | github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= 51 | github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= 52 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 53 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 54 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 55 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= 56 | github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= 57 | github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA= 58 | github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E= 59 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 60 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 61 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 62 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 63 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 64 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 65 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 66 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 67 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 68 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 69 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 70 | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= 71 | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= 72 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A= 73 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0= 74 | go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= 75 | go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= 76 | go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= 77 | go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= 78 | go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= 79 | go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= 80 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 81 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 82 | golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= 83 | golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= 84 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 85 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 86 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 87 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 88 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 89 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 90 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 91 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 92 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 93 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 94 | golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= 95 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 96 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 97 | golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= 98 | golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 99 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 100 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 101 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 102 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 103 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 104 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 105 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 106 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 107 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 108 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 109 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 110 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 111 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 112 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 113 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 114 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 115 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 116 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 117 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 118 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 119 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 120 | google.golang.org/api v0.185.0 h1:ENEKk1k4jW8SmmaT6RE+ZasxmxezCrD5Vw4npvr+pAU= 121 | google.golang.org/api v0.185.0/go.mod h1:HNfvIkJGlgrIlrbYkAm9W9IdkmKZjOTVh33YltygGbg= 122 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 123 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 124 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 125 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 126 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 127 | google.golang.org/genproto v0.0.0-20240617180043-68d350f18fd4 h1:CUiCqkPw1nNrNQzCCG4WA65m0nAmQiwXHpub3dNyruU= 128 | google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3 h1:QW9+G6Fir4VcRXVH8x3LilNAb6cxBGLa6+GM4hRwexE= 129 | google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3/go.mod h1:kdrSS/OiLkPrNUpzD4aHgCq2rVuC/YRxok32HXZ4vRE= 130 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 h1:Di6ANFilr+S60a4S61ZM00vLdw0IrQOSMS2/6mrnOU0= 131 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= 132 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 133 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 134 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 135 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 136 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= 137 | google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= 138 | google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= 139 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 140 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 141 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 142 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 143 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 144 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 145 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 146 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 147 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 148 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 149 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 150 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 151 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 152 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 153 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 154 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 155 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 156 | rsc.io/github v0.4.0 h1:vVC+/jLa5MGo4DemcM5iME66Xg7RRqMnDe3ToHlmqV0= 157 | rsc.io/github v0.4.0/go.mod h1:0OrXF7wdKg4IvgqAXIaeSWBj+1Ef07bTa1ZnkKntrn4= 158 | -------------------------------------------------------------------------------- /issue/edit.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "errors" 11 | "fmt" 12 | "io" 13 | "io/ioutil" 14 | "log" 15 | "os" 16 | "os/exec" 17 | "strconv" 18 | "strings" 19 | "time" 20 | 21 | "github.com/google/go-github/v62/github" 22 | ) 23 | 24 | func editIssue(project string, original []byte, issue *github.Issue) { 25 | updated := editText(original) 26 | if bytes.Equal(original, updated) { 27 | log.Print("no changes made") 28 | return 29 | } 30 | 31 | newIssue, _, err := writeIssue(project, issue, updated, false) 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | if newIssue != nil { 36 | issue = newIssue 37 | } 38 | log.Printf("https://github.com/%s/issues/%d updated", project, getInt(issue.Number)) 39 | } 40 | 41 | func editText(original []byte) []byte { 42 | f, err := ioutil.TempFile("", "issue-edit-") 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | if err := ioutil.WriteFile(f.Name(), original, 0600); err != nil { 47 | log.Fatal(err) 48 | } 49 | if err := runEditor(f.Name()); err != nil { 50 | log.Fatal(err) 51 | } 52 | updated, err := ioutil.ReadFile(f.Name()) 53 | if err != nil { 54 | log.Fatal(err) 55 | } 56 | name := f.Name() 57 | f.Close() 58 | os.Remove(name) 59 | return updated 60 | } 61 | 62 | func runEditor(filename string) error { 63 | ed := os.Getenv("VISUAL") 64 | if ed == "" { 65 | ed = os.Getenv("EDITOR") 66 | } 67 | if ed == "" { 68 | ed = "ed" 69 | } 70 | 71 | // If the editor contains spaces or other magic shell chars, 72 | // invoke it as a shell command. This lets people have 73 | // environment variables like "EDITOR=emacs -nw". 74 | // The magic list of characters and the idea of running 75 | // sh -c this way is taken from git/run-command.c. 76 | var cmd *exec.Cmd 77 | if strings.ContainsAny(ed, "|&;<>()$`\\\"' \t\n*?[#~=%") { 78 | cmd = exec.Command("sh", "-c", ed+` "$@"`, "$EDITOR", filename) 79 | } else { 80 | cmd = exec.Command(ed, filename) 81 | } 82 | 83 | cmd.Stdin = os.Stdin 84 | cmd.Stdout = os.Stdout 85 | cmd.Stderr = os.Stderr 86 | if err := cmd.Run(); err != nil { 87 | return fmt.Errorf("invoking editor: %v", err) 88 | } 89 | return nil 90 | } 91 | 92 | const bulkHeader = "\nBulk editing these issues:" 93 | 94 | func writeIssue(project string, old *github.Issue, updated []byte, isBulk bool) (issue *github.Issue, rate *github.Rate, err error) { 95 | var errbuf bytes.Buffer 96 | defer func() { 97 | if errbuf.Len() > 0 { 98 | err = errors.New(strings.TrimSpace(errbuf.String())) 99 | } 100 | }() 101 | 102 | sdata := string(updated) 103 | off := 0 104 | var edit github.IssueRequest 105 | var addLabels, removeLabels []string 106 | for _, line := range strings.SplitAfter(sdata, "\n") { 107 | off += len(line) 108 | line = strings.TrimSpace(line) 109 | if line == "" { 110 | break 111 | } 112 | switch { 113 | case strings.HasPrefix(line, "#"): 114 | continue 115 | 116 | case strings.HasPrefix(line, "Title:"): 117 | edit.Title = diff(line, "Title:", getString(old.Title)) 118 | 119 | case strings.HasPrefix(line, "State:"): 120 | edit.State = diff(line, "State:", getString(old.State)) 121 | 122 | case strings.HasPrefix(line, "Assignee:"): 123 | edit.Assignee = diff(line, "Assignee:", getUserLogin(old.Assignee)) 124 | 125 | case strings.HasPrefix(line, "Closed:"): 126 | continue 127 | 128 | case strings.HasPrefix(line, "Labels:"): 129 | if isBulk { 130 | addLabels, removeLabels = diffList2(line, "Labels:", getLabelNames(old.Labels)) 131 | } else { 132 | edit.Labels = diffList(line, "Labels:", getLabelNames(old.Labels)) 133 | } 134 | 135 | case strings.HasPrefix(line, "Milestone:"): 136 | edit.Milestone = findMilestone(&errbuf, project, diff(line, "Milestone:", getMilestoneTitle(old.Milestone))) 137 | 138 | case strings.HasPrefix(line, "URL:"): 139 | continue 140 | 141 | case strings.HasPrefix(line, "Reactions:"): 142 | continue 143 | 144 | default: 145 | fmt.Fprintf(&errbuf, "unknown summary line: %s\n", line) 146 | } 147 | } 148 | 149 | if errbuf.Len() > 0 { 150 | return nil, nil, nil 151 | } 152 | 153 | if getInt(old.Number) == 0 { 154 | comment := strings.TrimSpace(sdata[off:]) 155 | edit.Body = &comment 156 | issue, resp, err := client.Issues.Create(context.TODO(), projectOwner(project), projectRepo(project), &edit) 157 | if resp != nil { 158 | rate = &resp.Rate 159 | } 160 | if err != nil { 161 | fmt.Fprintf(&errbuf, "error creating issue: %v\n", err) 162 | return nil, rate, nil 163 | } 164 | return issue, rate, nil 165 | } 166 | 167 | if getInt(old.Number) == -1 { 168 | // Asking to just sanity check the text parsing. 169 | return nil, nil, nil 170 | } 171 | 172 | marker := "\nReported by " 173 | if isBulk { 174 | marker = bulkHeader 175 | } 176 | var comment string 177 | if i := strings.Index(sdata, marker); i >= off { 178 | comment = strings.TrimSpace(sdata[off:i]) 179 | } 180 | 181 | if comment == "" { 182 | comment = "" 183 | } 184 | 185 | var failed bool 186 | var did []string 187 | if comment != "" { 188 | _, resp, err := client.Issues.CreateComment(context.TODO(), projectOwner(project), projectRepo(project), getInt(old.Number), &github.IssueComment{ 189 | Body: &comment, 190 | }) 191 | if resp != nil { 192 | rate = &resp.Rate 193 | } 194 | if err != nil { 195 | fmt.Fprintf(&errbuf, "error saving comment: %v\n", err) 196 | failed = true 197 | } else { 198 | did = append(did, "saved comment") 199 | } 200 | } 201 | 202 | if edit.Title != nil || edit.State != nil || edit.Assignee != nil || edit.Labels != nil || edit.Milestone != nil { 203 | _, resp, err := client.Issues.Edit(context.TODO(), projectOwner(project), projectRepo(project), getInt(old.Number), &edit) 204 | if resp != nil { 205 | rate = &resp.Rate 206 | } 207 | if err != nil { 208 | fmt.Fprintf(&errbuf, "error changing metadata: %v\n", err) 209 | failed = true 210 | } else { 211 | did = append(did, "updated metadata") 212 | } 213 | } 214 | if len(addLabels) > 0 { 215 | _, resp, err := client.Issues.AddLabelsToIssue(context.TODO(), projectOwner(project), projectRepo(project), getInt(old.Number), addLabels) 216 | if resp != nil { 217 | rate = &resp.Rate 218 | } 219 | if err != nil { 220 | fmt.Fprintf(&errbuf, "error adding labels: %v\n", err) 221 | failed = true 222 | } else { 223 | if len(addLabels) == 1 { 224 | did = append(did, "added label "+addLabels[0]) 225 | } else { 226 | did = append(did, "added labels") 227 | } 228 | } 229 | } 230 | if len(removeLabels) > 0 { 231 | for _, label := range removeLabels { 232 | resp, err := client.Issues.RemoveLabelForIssue(context.TODO(), projectOwner(project), projectRepo(project), getInt(old.Number), label) 233 | if resp != nil { 234 | rate = &resp.Rate 235 | } 236 | if err != nil { 237 | fmt.Fprintf(&errbuf, "error removing label %s: %v\n", label, err) 238 | failed = true 239 | } else { 240 | did = append(did, "removed label "+label) 241 | } 242 | } 243 | } 244 | 245 | if failed && len(did) > 0 { 246 | var buf bytes.Buffer 247 | fmt.Fprintf(&buf, "%s", did[0]) 248 | for i := 1; i < len(did)-1; i++ { 249 | fmt.Fprintf(&buf, ", %s", did[i]) 250 | } 251 | if len(did) >= 2 { 252 | if len(did) >= 3 { 253 | fmt.Fprintf(&buf, ",") 254 | } 255 | fmt.Fprintf(&buf, " and %s", did[len(did)-1]) 256 | } 257 | all := buf.Bytes() 258 | all[0] -= 'a' - 'A' 259 | fmt.Fprintf(&errbuf, "(%s successfully.)\n", all) 260 | } 261 | return 262 | } 263 | 264 | func diffList(line, field string, old []string) *[]string { 265 | line = strings.TrimSpace(strings.TrimPrefix(line, field)) 266 | had := make(map[string]bool) 267 | for _, f := range old { 268 | had[f] = true 269 | } 270 | changes := false 271 | for _, f := range strings.Fields(line) { 272 | if !had[f] { 273 | changes = true 274 | } 275 | delete(had, f) 276 | } 277 | if len(had) != 0 { 278 | changes = true 279 | } 280 | if changes { 281 | ret := strings.Fields(line) 282 | if ret == nil { 283 | ret = []string{} 284 | } 285 | return &ret 286 | } 287 | return nil 288 | } 289 | 290 | func diffList2(line, field string, old []string) (added, removed []string) { 291 | line = strings.TrimSpace(strings.TrimPrefix(line, field)) 292 | had := make(map[string]bool) 293 | for _, f := range old { 294 | had[f] = true 295 | } 296 | for _, f := range strings.Fields(line) { 297 | if !had[f] { 298 | added = append(added, f) 299 | } 300 | delete(had, f) 301 | } 302 | if len(had) != 0 { 303 | for _, f := range old { 304 | if had[f] { 305 | removed = append(removed, f) 306 | } 307 | } 308 | } 309 | return 310 | } 311 | 312 | func findMilestone(w io.Writer, project string, name *string) *int { 313 | if name == nil { 314 | return nil 315 | } 316 | 317 | all, err := loadMilestones(project) 318 | if err != nil { 319 | fmt.Fprintf(w, "Error loading milestone list: %v\n\tIgnoring milestone change.\n", err) 320 | return nil 321 | } 322 | 323 | for _, m := range all { 324 | if getString(m.Title) == *name { 325 | return m.Number 326 | } 327 | } 328 | 329 | fmt.Fprintf(w, "Unknown milestone: %s\n", *name) 330 | return nil 331 | } 332 | 333 | func readBulkIDs(text []byte) []int { 334 | var ids []int 335 | for _, line := range strings.Split(string(text), "\n") { 336 | if i := strings.Index(line, "\t"); i >= 0 { 337 | line = line[:i] 338 | } 339 | if i := strings.Index(line, " "); i >= 0 { 340 | line = line[:i] 341 | } 342 | n, err := strconv.Atoi(line) 343 | if err != nil { 344 | continue 345 | } 346 | ids = append(ids, n) 347 | } 348 | return ids 349 | } 350 | 351 | func bulkEditStartFromText(project string, content []byte) (base *github.Issue, original []byte, err error) { 352 | ids := readBulkIDs(content) 353 | if len(ids) == 0 { 354 | return nil, nil, fmt.Errorf("found no issues in selection") 355 | } 356 | issues, err := bulkReadIssuesCached(project, ids) 357 | if err != nil { 358 | return nil, nil, err 359 | } 360 | base, original = bulkEditStart(issues) 361 | return base, original, nil 362 | } 363 | 364 | func suffix(n int) string { 365 | if n == 1 { 366 | return "" 367 | } 368 | return "s" 369 | } 370 | 371 | func bulkEditIssues(project string, issues []*github.Issue) { 372 | base, original := bulkEditStart(issues) 373 | updated := editText(original) 374 | if bytes.Equal(original, updated) { 375 | log.Print("no changes made") 376 | return 377 | } 378 | ids, err := bulkWriteIssue(project, base, updated, func(s string) { log.Print(s) }) 379 | if err != nil { 380 | errText := strings.Replace(err.Error(), "\n", "\t\n", -1) 381 | if len(ids) > 0 { 382 | log.Fatalf("updated %d issue%s with errors:\n\t%v", len(ids), suffix(len(ids)), errText) 383 | } 384 | log.Fatal(errText) 385 | } 386 | log.Printf("updated %d issue%s", len(ids), suffix(len(ids))) 387 | } 388 | 389 | func bulkEditStart(issues []*github.Issue) (*github.Issue, []byte) { 390 | common := new(github.Issue) 391 | for i, issue := range issues { 392 | if i == 0 { 393 | common.State = issue.State 394 | common.Assignee = issue.Assignee 395 | common.Labels = issue.Labels 396 | common.Milestone = issue.Milestone 397 | continue 398 | } 399 | if common.State != nil && getString(common.State) != getString(issue.State) { 400 | common.State = nil 401 | } 402 | if common.Assignee != nil && getUserLogin(common.Assignee) != getUserLogin(issue.Assignee) { 403 | common.Assignee = nil 404 | } 405 | if common.Milestone != nil && getMilestoneTitle(common.Milestone) != getMilestoneTitle(issue.Milestone) { 406 | common.Milestone = nil 407 | } 408 | common.Labels = commonLabels(common.Labels, issue.Labels) 409 | } 410 | 411 | var buf bytes.Buffer 412 | fmt.Fprintf(&buf, "State: %s\n", getString(common.State)) 413 | fmt.Fprintf(&buf, "Assignee: %s\n", getUserLogin(common.Assignee)) 414 | fmt.Fprintf(&buf, "Labels: %s\n", strings.Join(getLabelNames(common.Labels), " ")) 415 | fmt.Fprintf(&buf, "Milestone: %s\n", getMilestoneTitle(common.Milestone)) 416 | fmt.Fprintf(&buf, "\n\n") 417 | fmt.Fprintf(&buf, "%s\n", bulkHeader) 418 | for _, issue := range issues { 419 | fmt.Fprintf(&buf, "%d\t%s\n", getInt(issue.Number), getString(issue.Title)) 420 | } 421 | 422 | return common, buf.Bytes() 423 | } 424 | 425 | func commonString(x, y string) string { 426 | if x != y { 427 | x = "" 428 | } 429 | return x 430 | } 431 | 432 | func commonLabels(x, y []*github.Label) []*github.Label { 433 | if len(x) == 0 || len(y) == 0 { 434 | return nil 435 | } 436 | have := make(map[string]bool) 437 | for _, lab := range y { 438 | have[getString(lab.Name)] = true 439 | } 440 | var out []*github.Label 441 | for _, lab := range x { 442 | if have[getString(lab.Name)] { 443 | out = append(out, lab) 444 | } 445 | } 446 | return out 447 | } 448 | 449 | func bulkWriteIssue(project string, old *github.Issue, updated []byte, status func(string)) (ids []int, err error) { 450 | i := bytes.Index(updated, []byte(bulkHeader)) 451 | if i < 0 { 452 | return nil, fmt.Errorf("cannot find bulk edit issue list") 453 | } 454 | ids = readBulkIDs(updated[i:]) 455 | if len(ids) == 0 { 456 | return nil, fmt.Errorf("found no issues in bulk edit issue list") 457 | } 458 | 459 | // Make a copy of the issue to modify. 460 | x := *old 461 | old = &x 462 | 463 | // Try a write to issue -1, checking for formatting only. 464 | old.Number = new(int) 465 | *old.Number = -1 466 | _, rate, err := writeIssue(project, old, updated, true) 467 | if err != nil { 468 | return nil, err 469 | } 470 | 471 | // Apply to all issues in list. 472 | suffix := "" 473 | if len(ids) != 1 { 474 | suffix = "s" 475 | } 476 | status(fmt.Sprintf("updating %d issue%s", len(ids), suffix)) 477 | 478 | failed := false 479 | for index, number := range ids { 480 | if index%10 == 0 && index > 0 { 481 | status(fmt.Sprintf("updated %d/%d issues", index, len(ids))) 482 | } 483 | // Check rate limits here (in contrast to everywhere else in this program) 484 | // to avoid needless failure halfway through the loop. 485 | for rate != nil && rate.Limit > 0 && rate.Remaining == 0 { 486 | delta := (rate.Reset.Sub(time.Now())/time.Minute + 2) * time.Minute 487 | if delta < 0 { 488 | delta = 2 * time.Minute 489 | } 490 | status(fmt.Sprintf("updated %d/%d issues; pausing %d minutes to respect GitHub rate limit", index, len(ids), int(delta/time.Minute))) 491 | time.Sleep(delta) 492 | limits, _, err := client.RateLimits(context.TODO()) 493 | if err != nil { 494 | status(fmt.Sprintf("reading rate limit: %v", err)) 495 | } 496 | rate = nil 497 | if limits != nil { 498 | rate = limits.Core 499 | } 500 | } 501 | *old.Number = number 502 | if _, rate, err = writeIssue(project, old, updated, true); err != nil { 503 | status(fmt.Sprintf("writing #%d: %s", number, strings.Replace(err.Error(), "\n", "\n\t", -1))) 504 | failed = true 505 | } 506 | } 507 | 508 | if failed { 509 | return ids, fmt.Errorf("failed to update all issues") 510 | } 511 | return ids, nil 512 | } 513 | 514 | func projectOwner(project string) string { 515 | return project[:strings.Index(project, "/")] 516 | } 517 | 518 | func projectRepo(project string) string { 519 | return project[strings.Index(project, "/")+1:] 520 | } 521 | -------------------------------------------------------------------------------- /issue/acme.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Originally code.google.com/p/rsc/cmd/issue/acme.go. 6 | 7 | package main 8 | 9 | import ( 10 | "bufio" 11 | "bytes" 12 | "context" 13 | "flag" 14 | "fmt" 15 | "log" 16 | "os" 17 | "regexp" 18 | "strconv" 19 | "strings" 20 | "sync" 21 | "time" 22 | 23 | "9fans.net/go/acme" 24 | "9fans.net/go/plumb" 25 | "github.com/google/go-github/v62/github" 26 | ) 27 | 28 | func (w *awin) project() string { 29 | p := w.prefix 30 | p = strings.TrimPrefix(p, "/issue/") 31 | i := strings.Index(p, "/") 32 | if i >= 0 { 33 | j := strings.Index(p[i+1:], "/") 34 | if j >= 0 { 35 | p = p[:i+1+j] 36 | } 37 | } 38 | return p 39 | } 40 | 41 | func acmeMode() { 42 | var dummy awin 43 | dummy.prefix = "/issue/" + *project + "/" 44 | if flag.NArg() > 0 { 45 | // TODO(rsc): Without -a flag, the query is conatenated into one query. 46 | // Decide which behavior should be used, and use it consistently. 47 | // TODO(rsc): Block this look from doing the multiline selection mode? 48 | for _, arg := range flag.Args() { 49 | if dummy.Look(arg) { 50 | continue 51 | } 52 | if arg == "new" { 53 | dummy.createIssue() 54 | continue 55 | } 56 | dummy.newSearch(dummy.prefix, "search", arg) 57 | } 58 | } else { 59 | dummy.Look("all") 60 | } 61 | 62 | go dummy.plumbserve() 63 | 64 | select {} 65 | } 66 | 67 | func (w *awin) plumbserve() { 68 | fid, err := plumb.Open("githubissue", 0) 69 | if err != nil { 70 | w.Err(fmt.Sprintf("plumb: %v", err)) 71 | return 72 | } 73 | r := bufio.NewReader(fid) 74 | for { 75 | var m plumb.Message 76 | if err := m.Recv(r); err != nil { 77 | w.Err(fmt.Sprintf("plumb recv: %v", err)) 78 | return 79 | } 80 | if m.Type != "text" { 81 | w.Err(fmt.Sprintf("plumb recv: unexpected type: %s\n", m.Type)) 82 | continue 83 | } 84 | if m.Dst != "githubissue" { 85 | w.Err(fmt.Sprintf("plumb recv: unexpected dst: %s\n", m.Dst)) 86 | continue 87 | } 88 | // TODO use m.Dir 89 | data := string(m.Data) 90 | var project, what string 91 | if strings.HasPrefix(data, "/issue/") { 92 | project = data[len("/issue/"):] 93 | i := strings.LastIndex(project, "/") 94 | if i < 0 { 95 | w.Err(fmt.Sprintf("plumb recv: bad text %q", data)) 96 | continue 97 | } 98 | project, what = project[:i], project[i+1:] 99 | } else { 100 | i := strings.Index(data, "#") 101 | if i < 0 { 102 | w.Err(fmt.Sprintf("plumb recv: bad text %q", data)) 103 | continue 104 | } 105 | project, what = data[:i], data[i+1:] 106 | } 107 | if strings.Count(project, "/") != 1 { 108 | w.Err(fmt.Sprintf("plumb recv: bad text %q", data)) 109 | continue 110 | } 111 | var plummy awin 112 | plummy.prefix = "/issue/" + project + "/" 113 | if !plummy.Look(what) { 114 | w.Err(fmt.Sprintf("plumb recv: can't look %s%s", plummy.prefix, what)) 115 | } 116 | } 117 | } 118 | 119 | const ( 120 | modeSingle = 1 + iota 121 | modeQuery 122 | modeCreate 123 | modeMilestone 124 | modeBulk 125 | ) 126 | 127 | type awin struct { 128 | *acme.Win 129 | prefix string 130 | mode int 131 | query string 132 | id int 133 | github *github.Issue 134 | title string 135 | sortByNumber bool // otherwise sort by title 136 | } 137 | 138 | var all struct { 139 | sync.Mutex 140 | m map[*acme.Win]*awin 141 | } 142 | 143 | func (w *awin) exit() { 144 | all.Lock() 145 | defer all.Unlock() 146 | if all.m[w.Win] == w { 147 | delete(all.m, w.Win) 148 | } 149 | if len(all.m) == 0 { 150 | os.Exit(0) 151 | } 152 | } 153 | 154 | func (w *awin) new(prefix, title string) *awin { 155 | all.Lock() 156 | defer all.Unlock() 157 | if all.m == nil { 158 | all.m = make(map[*acme.Win]*awin) 159 | } 160 | w1 := new(awin) 161 | w1.title = title 162 | var err error 163 | w1.Win, err = acme.New() 164 | if err != nil { 165 | log.Printf("creating acme window: %v", err) 166 | time.Sleep(10 * time.Millisecond) 167 | w1.Win, err = acme.New() 168 | if err != nil { 169 | log.Fatalf("creating acme window again: %v", err) 170 | } 171 | } 172 | w1.prefix = prefix 173 | w1.SetErrorPrefix(w1.prefix) 174 | w1.Name(w1.prefix + title) 175 | all.m[w1.Win] = w1 176 | return w1 177 | } 178 | 179 | func (w *awin) show(title string) bool { 180 | return acme.Show(w.prefix+title) != nil 181 | } 182 | 183 | var numRE = regexp.MustCompile(`(?m)^#[0-9]+\t`) 184 | var repoHashRE = regexp.MustCompile(`\A([A-Za-z0-9_]+/[A-Za-z0-9_]+)#(all|[0-9]+)\z`) 185 | 186 | var milecache struct { 187 | sync.Mutex 188 | list map[string][]*github.Milestone 189 | } 190 | 191 | func cachedMilestones(project string) []*github.Milestone { 192 | milecache.Lock() 193 | if milecache.list == nil { 194 | milecache.list = make(map[string][]*github.Milestone) 195 | } 196 | if milecache.list[project] == nil { 197 | milecache.list[project], _ = loadMilestones(project) 198 | } 199 | list := milecache.list[project] 200 | milecache.Unlock() 201 | return list 202 | } 203 | 204 | func (w *awin) Look(text string) bool { 205 | ids := readBulkIDs([]byte(text)) 206 | if len(ids) > 0 { 207 | for _, id := range ids { 208 | text := fmt.Sprint(id) 209 | if w.show(text) { 210 | continue 211 | } 212 | w.newIssue(w.prefix, text, id) 213 | } 214 | return true 215 | } 216 | 217 | if text == "all" { 218 | if w.show("all") { 219 | return true 220 | } 221 | w.newSearch(w.prefix, "all", "") 222 | return true 223 | } 224 | if text == "Milestone" || text == "Milestones" || text == "milestone" { 225 | if w.show("milestone") { 226 | return true 227 | } 228 | w.newMilestoneList() 229 | return true 230 | } 231 | list := cachedMilestones(w.project()) 232 | for _, m := range list { 233 | if getString(m.Title) == text { 234 | if w.show(text) { 235 | return true 236 | } 237 | w.newSearch(w.prefix, text, "milestone:"+text) 238 | return true 239 | } 240 | } 241 | 242 | if n, _ := strconv.Atoi(strings.TrimPrefix(text, "#")); 0 < n && n < 1000000 { 243 | text = strings.TrimPrefix(text, "#") 244 | if w.show(text) { 245 | return true 246 | } 247 | w.newIssue(w.prefix, text, n) 248 | return true 249 | } 250 | 251 | if m := repoHashRE.FindStringSubmatch(text); m != nil { 252 | project := m[1] 253 | what := m[2] 254 | prefix := "/issue/" + project + "/" 255 | if acme.Show(prefix+what) != nil { 256 | return true 257 | } 258 | if what == "all" { 259 | w.newSearch(prefix, what, "") 260 | return true 261 | } 262 | if n, _ := strconv.Atoi(what); 0 < n && n < 1000000 { 263 | w.newIssue(prefix, what, n) 264 | return true 265 | } 266 | return false 267 | } 268 | 269 | if m := numRE.FindAllString(text, -1); m != nil { 270 | for _, s := range m { 271 | w.Look(strings.TrimSpace(strings.TrimPrefix(s, "#"))) 272 | } 273 | return true 274 | } 275 | return false 276 | } 277 | 278 | func (w *awin) setMilestone(milestone, text string) { 279 | var buf bytes.Buffer 280 | id := findMilestone(&buf, w.project(), &milestone) 281 | if buf.Len() > 0 { 282 | w.Err(strings.TrimSpace(buf.String())) 283 | } 284 | if id == nil { 285 | return 286 | } 287 | milestoneID := *id 288 | 289 | stop := w.Blink() 290 | defer stop() 291 | if w.mode == modeSingle { 292 | w.setMilestone1(milestoneID, w.id) 293 | w.load() 294 | return 295 | } 296 | if n, _ := strconv.Atoi(strings.TrimPrefix(text, "#")); 0 < n && n < 100000 { 297 | w.setMilestone1(milestoneID, n) 298 | return 299 | } 300 | if m := numRE.FindAllString(text, -1); m != nil { 301 | for _, s := range m { 302 | n, _ := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(s, "#"))) 303 | if 0 < n && n < 100000 { 304 | w.setMilestone1(milestoneID, n) 305 | } 306 | } 307 | return 308 | } 309 | } 310 | 311 | func (w *awin) setMilestone1(milestoneID, n int) { 312 | var edit github.IssueRequest 313 | edit.Milestone = &milestoneID 314 | 315 | _, _, err := client.Issues.Edit(context.TODO(), projectOwner(w.project()), projectRepo(w.project()), n, &edit) 316 | if err != nil { 317 | w.Err(fmt.Sprintf("Error changing issue #%d: %v", n, err)) 318 | } 319 | } 320 | 321 | func (w *awin) createIssue() { 322 | w = w.new(w.prefix, "new") 323 | w.mode = modeCreate 324 | w.Ctl("cleartag") 325 | w.Fprintf("tag", " Put Search ") 326 | go w.load() 327 | go w.loop() 328 | } 329 | 330 | func (w *awin) newIssue(prefix, title string, id int) { 331 | w = w.new(prefix, title) 332 | w.mode = modeSingle 333 | w.id = id 334 | w.Ctl("cleartag") 335 | w.Fprintf("tag", " Get Put Look ") 336 | go w.load() 337 | go w.loop() 338 | } 339 | 340 | func (w *awin) newBulkEdit(body []byte) { 341 | w = w.new(w.prefix, "bulk-edit/") 342 | w.mode = modeBulk 343 | w.query = "" 344 | w.Ctl("cleartag") 345 | w.Fprintf("tag", " New Get Sort Search ") 346 | w.Write("body", append([]byte("Loading...\n\n"), body...)) 347 | go w.load() 348 | go w.loop() 349 | } 350 | 351 | func (w *awin) newMilestoneList() { 352 | w = w.new(w.prefix, "milestone") 353 | w.mode = modeMilestone 354 | w.query = "" 355 | w.Ctl("cleartag") 356 | w.Fprintf("tag", " New Get Sort Search ") 357 | w.Write("body", []byte("Loading...")) 358 | go w.load() 359 | go w.loop() 360 | } 361 | 362 | func (w *awin) newSearch(prefix, title, query string) { 363 | w = w.new(prefix, title) 364 | w.mode = modeQuery 365 | w.query = query 366 | w.Ctl("cleartag") 367 | w.Fprintf("tag", " New Get Bulk Sort Search ") 368 | w.Write("body", []byte("Loading...")) 369 | go w.load() 370 | go w.loop() 371 | } 372 | 373 | var createTemplate = `Title: 374 | Assignee: 375 | Labels: 376 | Milestone: 377 | 378 | 379 | 380 | ` 381 | 382 | func (w *awin) load() { 383 | switch w.mode { 384 | case modeCreate: 385 | w.Clear() 386 | w.Write("body", []byte(createTemplate)) 387 | w.Ctl("clean") 388 | 389 | case modeSingle: 390 | var buf bytes.Buffer 391 | stop := w.Blink() 392 | issue, err := showIssue(&buf, w.project(), w.id) 393 | stop() 394 | w.Clear() 395 | if err != nil { 396 | w.Write("body", []byte(err.Error())) 397 | break 398 | } 399 | w.Write("body", buf.Bytes()) 400 | w.Ctl("clean") 401 | w.github = issue 402 | 403 | case modeMilestone: 404 | stop := w.Blink() 405 | milestones, err := loadMilestones(w.project()) 406 | milecache.Lock() 407 | if milecache.list == nil { 408 | milecache.list = make(map[string][]*github.Milestone) 409 | } 410 | milecache.list[w.project()] = milestones 411 | milecache.Unlock() 412 | stop() 413 | w.Clear() 414 | if err != nil { 415 | w.Fprintf("body", "Error loading milestones: %v\n", err) 416 | break 417 | } 418 | var buf bytes.Buffer 419 | for _, m := range milestones { 420 | fmt.Fprintf(&buf, "%s\t%s\t%d\n", getTime(m.DueOn).Format("2006-01-02"), getString(m.Title), getInt(m.OpenIssues)) 421 | } 422 | w.PrintTabbed(buf.String()) 423 | w.Ctl("clean") 424 | 425 | case modeQuery: 426 | var buf bytes.Buffer 427 | stop := w.Blink() 428 | err := showQuery(&buf, w.project(), w.query) 429 | if w.title == "all" { 430 | cachedMilestones(w.project()) 431 | } 432 | stop() 433 | w.Clear() 434 | if err != nil { 435 | w.Write("body", []byte(err.Error())) 436 | break 437 | } 438 | if w.title == "all" { 439 | var names []string 440 | for _, m := range cachedMilestones(w.project()) { 441 | names = append(names, getString(m.Title)) 442 | } 443 | if len(names) > 0 { 444 | w.Fprintf("body", "Milestones: %s\n\n", strings.Join(names, " ")) 445 | } 446 | } 447 | if w.title == "search" { 448 | w.Fprintf("body", "Search %s\n\n", w.query) 449 | } 450 | w.PrintTabbed(buf.String()) 451 | w.Ctl("clean") 452 | 453 | case modeBulk: 454 | stop := w.Blink() 455 | body, err := w.ReadAll("body") 456 | if err != nil { 457 | w.Err(fmt.Sprintf("%v", err)) 458 | stop() 459 | break 460 | } 461 | base, original, err := bulkEditStartFromText(w.project(), body) 462 | stop() 463 | if err != nil { 464 | w.Err(fmt.Sprintf("%v", err)) 465 | break 466 | } 467 | w.Clear() 468 | w.PrintTabbed(string(original)) 469 | w.Ctl("clean") 470 | w.github = base 471 | } 472 | 473 | w.Addr("0") 474 | w.Ctl("dot=addr") 475 | w.Ctl("show") 476 | } 477 | 478 | func diff(line, field, old string) *string { 479 | old = strings.TrimSpace(old) 480 | line = strings.TrimSpace(strings.TrimPrefix(line, field)) 481 | if old == line { 482 | return nil 483 | } 484 | return &line 485 | } 486 | 487 | func (w *awin) put() { 488 | stop := w.Blink() 489 | defer stop() 490 | switch w.mode { 491 | case modeSingle, modeCreate: 492 | old := w.github 493 | if w.mode == modeCreate { 494 | old = new(github.Issue) 495 | } 496 | data, err := w.ReadAll("body") 497 | if err != nil { 498 | w.Err(fmt.Sprintf("Put: %v", err)) 499 | return 500 | } 501 | issue, _, err := writeIssue(w.project(), old, data, false) 502 | if err != nil { 503 | w.Err(err.Error()) 504 | return 505 | } 506 | if w.mode == modeCreate { 507 | w.mode = modeSingle 508 | w.id = getInt(issue.Number) 509 | w.title = fmt.Sprint(w.id) 510 | w.Name(w.prefix + w.title) 511 | w.github = issue 512 | } 513 | w.load() 514 | 515 | case modeBulk: 516 | data, err := w.ReadAll("body") 517 | if err != nil { 518 | w.Err(fmt.Sprintf("Put: %v", err)) 519 | return 520 | } 521 | ids, err := bulkWriteIssue(w.project(), w.github, data, func(s string) { w.Err("Put: " + s) }) 522 | if err != nil { 523 | errText := strings.Replace(err.Error(), "\n", "\t\n", -1) 524 | if len(ids) > 0 { 525 | w.Err(fmt.Sprintf("updated %d issue%s with errors:\n\t%v", len(ids), suffix(len(ids)), errText)) 526 | break 527 | } 528 | w.Err(fmt.Sprintf("%s", errText)) 529 | break 530 | } 531 | w.Err(fmt.Sprintf("updated %d issue%s", len(ids), suffix(len(ids)))) 532 | 533 | case modeMilestone: 534 | w.Err("cannot Put milestone list") 535 | 536 | case modeQuery: 537 | w.Err("cannot Put issue list") 538 | } 539 | } 540 | 541 | func (w *awin) sort() { 542 | if err := w.Addr("0/^[0-9]/,"); err != nil { 543 | w.Err("nothing to sort") 544 | } 545 | var less func(string, string) bool 546 | if w.sortByNumber { 547 | less = func(x, y string) bool { return lineNumber(x) > lineNumber(y) } 548 | } else { 549 | less = func(x, y string) bool { return skipField(x) < skipField(y) } 550 | } 551 | if err := w.Sort(less); err != nil { 552 | w.Err(err.Error()) 553 | } 554 | w.Addr("0") 555 | w.Ctl("dot=addr") 556 | w.Ctl("show") 557 | } 558 | 559 | func lineNumber(s string) int { 560 | n := 0 561 | for j := 0; j < len(s) && '0' <= s[j] && s[j] <= '9'; j++ { 562 | n = n*10 + int(s[j]-'0') 563 | } 564 | return n 565 | } 566 | 567 | func skipField(s string) string { 568 | i := strings.Index(s, "\t") 569 | if i < 0 { 570 | return s 571 | } 572 | for i < len(s) && s[i+1] == '\t' { 573 | i++ 574 | } 575 | return s[i:] 576 | } 577 | 578 | func (w *awin) Execute(cmd string) bool { 579 | switch cmd { 580 | case "Get": 581 | w.load() 582 | return true 583 | case "Put": 584 | w.put() 585 | return true 586 | case "Del": 587 | w.Ctl("del") 588 | return true 589 | case "New": 590 | w.createIssue() 591 | return true 592 | case "Sort": 593 | if w.mode != modeQuery { 594 | w.Err("can only sort issue list windows") 595 | break 596 | } 597 | w.sortByNumber = !w.sortByNumber 598 | w.sort() 599 | return true 600 | case "Bulk": 601 | // TODO(rsc): If Bulk has an argument, treat as search query and use results? 602 | if w.mode != modeQuery { 603 | w.Err("can only start bulk edit in issue list windows") 604 | return true 605 | } 606 | text := w.Selection() 607 | if text == "" { 608 | data, err := w.ReadAll("body") 609 | if err != nil { 610 | w.Err(fmt.Sprintf("%v", err)) 611 | return true 612 | } 613 | text = string(data) 614 | } 615 | w.newBulkEdit([]byte(text)) 616 | return true 617 | } 618 | 619 | if strings.HasPrefix(cmd, "Search ") { 620 | w.newSearch(w.prefix, "search", strings.TrimSpace(strings.TrimPrefix(cmd, "Search"))) 621 | return true 622 | } 623 | if strings.HasPrefix(cmd, "Milestone ") { 624 | text := w.Selection() 625 | w.setMilestone(strings.TrimSpace(strings.TrimPrefix(cmd, "Milestone")), text) 626 | return true 627 | } 628 | 629 | return false 630 | } 631 | 632 | func (w *awin) loop() { 633 | defer w.exit() 634 | w.EventLoop(w) 635 | } 636 | -------------------------------------------------------------------------------- /issue.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package github 6 | 7 | import ( 8 | "fmt" 9 | "time" 10 | 11 | "rsc.io/github/schema" 12 | ) 13 | 14 | const issueFields = ` 15 | number 16 | title 17 | id 18 | author { __typename login } 19 | closed 20 | closedAt 21 | createdAt 22 | lastEditedAt 23 | milestone { id number title } 24 | repository { name owner { __typename login } } 25 | body 26 | url 27 | labels(first: 100) { 28 | nodes { 29 | name 30 | description 31 | id 32 | repository { name owner { __typename login } } 33 | } 34 | } 35 | ` 36 | 37 | func (c *Client) Issue(org, repo string, n int) (*Issue, error) { 38 | graphql := ` 39 | query($Org: String!, $Repo: String!, $Number: Int!) { 40 | organization(login: $Org) { 41 | repository(name: $Repo) { 42 | issue(number: $Number) { 43 | ` + issueFields + ` 44 | } 45 | } 46 | } 47 | } 48 | ` 49 | 50 | vars := Vars{"Org": org, "Repo": repo, "Number": n} 51 | q, err := c.GraphQLQuery(graphql, vars) 52 | if err != nil { 53 | return nil, err 54 | } 55 | issue := toIssue(q.Organization.Repository.Issue) 56 | return issue, nil 57 | } 58 | 59 | func (c *Client) SearchLabels(org, repo, query string) ([]*Label, error) { 60 | graphql := ` 61 | query($Org: String!, $Repo: String!, $Query: String, $Cursor: String) { 62 | repository(owner: $Org, name: $Repo) { 63 | labels(first: 100, query: $Query, after: $Cursor) { 64 | pageInfo { 65 | hasNextPage 66 | endCursor 67 | } 68 | totalCount 69 | nodes { 70 | name 71 | description 72 | id 73 | repository { name owner { __typename login } } 74 | } 75 | } 76 | } 77 | } 78 | ` 79 | 80 | vars := Vars{"Org": org, "Repo": repo} 81 | if query != "" { 82 | vars["Query"] = query 83 | } 84 | return collect(c, graphql, vars, toLabel, 85 | func(q *schema.Query) pager[*schema.Label] { return q.Repository.Labels }, 86 | ) 87 | } 88 | 89 | func (c *Client) Discussions(org, repo string) ([]*Discussion, error) { 90 | graphql := ` 91 | query($Org: String!, $Repo: String!, $Cursor: String) { 92 | repository(owner: $Org, name: $Repo) { 93 | discussions(first: 100, after: $Cursor) { 94 | pageInfo { 95 | hasNextPage 96 | endCursor 97 | } 98 | totalCount 99 | nodes { 100 | locked 101 | closed 102 | closedAt 103 | number 104 | title 105 | repository { name owner { __typename login } } 106 | body 107 | } 108 | } 109 | } 110 | } 111 | ` 112 | 113 | vars := Vars{"Org": org, "Repo": repo} 114 | return collect(c, graphql, vars, toDiscussion, 115 | func(q *schema.Query) pager[*schema.Discussion] { return q.Repository.Discussions }, 116 | ) 117 | } 118 | 119 | func (c *Client) SearchMilestones(org, repo, query string) ([]*Milestone, error) { 120 | graphql := ` 121 | query($Org: String!, $Repo: String!, $Query: String, $Cursor: String) { 122 | repository(owner: $Org, name: $Repo) { 123 | milestones(first: 100, query: $Query, after: $Cursor) { 124 | pageInfo { 125 | hasNextPage 126 | endCursor 127 | } 128 | totalCount 129 | nodes { 130 | id 131 | number 132 | title 133 | } 134 | } 135 | } 136 | } 137 | ` 138 | 139 | vars := Vars{"Org": org, "Repo": repo} 140 | if query != "" { 141 | vars["Query"] = query 142 | } 143 | return collect(c, graphql, vars, toMilestone, 144 | func(q *schema.Query) pager[*schema.Milestone] { return q.Repository.Milestones }, 145 | ) 146 | } 147 | 148 | func (c *Client) IssueComments(issue *Issue) ([]*IssueComment, error) { 149 | graphql := ` 150 | query($Org: String!, $Repo: String!, $Number: Int!, $Cursor: String) { 151 | repository(owner: $Org, name: $Repo) { 152 | issue(number: $Number) { 153 | comments(first: 100, after: $Cursor) { 154 | pageInfo { 155 | hasNextPage 156 | endCursor 157 | } 158 | totalCount 159 | nodes { 160 | author { __typename login } 161 | id 162 | body 163 | createdAt 164 | publishedAt 165 | updatedAt 166 | url 167 | issue { number } 168 | repository { name owner { __typename login } } 169 | } 170 | } 171 | } 172 | } 173 | } 174 | ` 175 | 176 | vars := Vars{"Org": issue.Owner, "Repo": issue.Repo, "Number": issue.Number} 177 | return collect(c, graphql, vars, toIssueComment, 178 | func(q *schema.Query) pager[*schema.IssueComment] { return q.Repository.Issue.Comments }, 179 | ) 180 | } 181 | 182 | func (c *Client) UserComments(user string) ([]*IssueComment, error) { 183 | graphql := ` 184 | query($User: String!, $Cursor: String) { 185 | user(login: $User) { 186 | issueComments(first: 100, after: $Cursor) { 187 | pageInfo { 188 | hasNextPage 189 | endCursor 190 | } 191 | totalCount 192 | nodes { 193 | author { __typename login } 194 | id 195 | body 196 | createdAt 197 | publishedAt 198 | updatedAt 199 | issue { number } 200 | repository { name owner { __typename login } } 201 | } 202 | } 203 | } 204 | } 205 | ` 206 | 207 | vars := Vars{"User": user} 208 | return collect(c, graphql, vars, toIssueComment, 209 | func(q *schema.Query) pager[*schema.IssueComment] { return q.User.IssueComments }, 210 | ) 211 | } 212 | 213 | func (c *Client) AddIssueComment(issue *Issue, text string) error { 214 | graphql := ` 215 | mutation($ID: ID!, $Text: String!) { 216 | addComment(input: {subjectId: $ID, body: $Text}) { 217 | clientMutationId 218 | } 219 | } 220 | ` 221 | _, err := c.GraphQLMutation(graphql, Vars{"ID": issue.ID, "Text": text}) 222 | return err 223 | } 224 | 225 | func (c *Client) CloseIssue(issue *Issue) error { 226 | graphql := ` 227 | mutation($ID: ID!) { 228 | closeIssue(input: {issueId: $ID}) { 229 | clientMutationId 230 | } 231 | } 232 | ` 233 | _, err := c.GraphQLMutation(graphql, Vars{"ID": issue.ID}) 234 | return err 235 | } 236 | 237 | func (c *Client) ReopenIssue(issue *Issue) error { 238 | graphql := ` 239 | mutation($ID: ID!) { 240 | reopenIssue(input: {issueId: $ID}) { 241 | clientMutationId 242 | } 243 | } 244 | ` 245 | _, err := c.GraphQLMutation(graphql, Vars{"ID": issue.ID}) 246 | return err 247 | } 248 | 249 | func (c *Client) AddIssueLabels(issue *Issue, labels ...*Label) error { 250 | var labelIDs []string 251 | for _, lab := range labels { 252 | labelIDs = append(labelIDs, lab.ID) 253 | } 254 | graphql := ` 255 | mutation($Issue: ID!, $Labels: [ID!]!) { 256 | addLabelsToLabelable(input: {labelableId: $Issue, labelIds: $Labels}) { 257 | clientMutationId 258 | } 259 | } 260 | ` 261 | _, err := c.GraphQLMutation(graphql, Vars{"Issue": issue.ID, "Labels": labelIDs}) 262 | return err 263 | } 264 | 265 | func (c *Client) RemoveIssueLabels(issue *Issue, labels ...*Label) error { 266 | var labelIDs []string 267 | for _, lab := range labels { 268 | labelIDs = append(labelIDs, lab.ID) 269 | } 270 | graphql := ` 271 | mutation($Issue: ID!, $Labels: [ID!]!) { 272 | removeLabelsFromLabelable(input: {labelableId: $Issue, labelIds: $Labels}) { 273 | clientMutationId 274 | } 275 | } 276 | ` 277 | _, err := c.GraphQLMutation(graphql, Vars{"Issue": issue.ID, "Labels": labelIDs}) 278 | return err 279 | } 280 | 281 | func (c *Client) CreateIssue(repo *Repo, title, body string, extra ...any) (*Issue, error) { 282 | var labelIDs []string 283 | var projectIDs []string 284 | for _, x := range extra { 285 | switch x := x.(type) { 286 | default: 287 | return nil, fmt.Errorf("cannot create issue with extra of type %T", x) 288 | case *Label: 289 | labelIDs = append(labelIDs, x.ID) 290 | case *Project: 291 | projectIDs = append(projectIDs, x.ID) 292 | } 293 | } 294 | graphql := ` 295 | mutation($Repo: ID!, $Title: String!, $Body: String!, $Labels: [ID!]) { 296 | createIssue(input: {repositoryId: $Repo, title: $Title, body: $Body, labelIds: $Labels}) { 297 | clientMutationId 298 | issue { 299 | ` + issueFields + ` 300 | } 301 | } 302 | } 303 | ` 304 | m, err := c.GraphQLMutation(graphql, Vars{"Repo": repo.ID, "Title": title, "Body": body, "Labels": labelIDs, "Projects": projectIDs}) 305 | if err != nil { 306 | return nil, err 307 | } 308 | issue := toIssue(m.CreateIssue.Issue) 309 | for _, id := range projectIDs { 310 | graphql := ` 311 | mutation($Project: ID!, $Issue: ID!) { 312 | addProjectV2ItemById(input: {projectId: $Project, contentId: $Issue}) { 313 | clientMutationId 314 | } 315 | } 316 | ` 317 | _, err := c.GraphQLMutation(graphql, Vars{"Project": id, "Issue": string(m.CreateIssue.Issue.Id)}) 318 | if err != nil { 319 | return issue, err 320 | } 321 | } 322 | return issue, nil 323 | } 324 | 325 | func (c *Client) RetitleIssue(issue *Issue, title string) error { 326 | graphql := ` 327 | mutation($Issue: ID!, $Title: String!) { 328 | updateIssue(input: {id: $Issue, title: $Title}) { 329 | clientMutationId 330 | } 331 | } 332 | ` 333 | _, err := c.GraphQLMutation(graphql, Vars{"Issue": issue.ID, "Title": title}) 334 | return err 335 | } 336 | 337 | func (c *Client) EditIssueComment(comment *IssueComment, body string) error { 338 | graphql := ` 339 | mutation($Comment: ID!, $Body: String!) { 340 | updateIssueComment(input: {id: $Comment, body: $Body}) { 341 | clientMutationId 342 | } 343 | } 344 | ` 345 | _, err := c.GraphQLMutation(graphql, Vars{"Comment": comment.ID, "Body": body}) 346 | return err 347 | } 348 | 349 | func (c *Client) DeleteIssue(issue *Issue) error { 350 | graphql := ` 351 | mutation($Issue: ID!) { 352 | deleteIssue(input: {issueId: $Issue}) { 353 | clientMutationId 354 | } 355 | } 356 | ` 357 | _, err := c.GraphQLMutation(graphql, Vars{"Issue": issue.ID}) 358 | return err 359 | } 360 | 361 | func (c *Client) RemilestoneIssue(issue *Issue, milestone *Milestone) error { 362 | graphql := ` 363 | mutation($Issue: ID!, $Milestone: ID!) { 364 | updateIssue(input: {id: $Issue, milestoneId: $Milestone}) { 365 | clientMutationId 366 | } 367 | } 368 | ` 369 | _, err := c.GraphQLMutation(graphql, Vars{"Issue": issue.ID, "Milestone": milestone.ID}) 370 | return err 371 | } 372 | 373 | func (c *Client) AddProjectIssue(project *Project, issue *Issue) (*ProjectItem, error) { 374 | const graphql = ` 375 | mutation AddItemToProject($projectId: ID!, $contentId: ID!) { 376 | addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { 377 | item { 378 | ` + projectItemFields + ` 379 | } 380 | } 381 | } 382 | ` 383 | m, err := c.GraphQLMutation(graphql, Vars{"projectId": project.ID, "contentId": issue.ID}) 384 | if err != nil { 385 | return nil, err 386 | } 387 | return project.toProjectItem(m.AddProjectV2ItemById.Item), nil 388 | } 389 | 390 | func (c *Client) SetProjectItemFieldOption(project *Project, item *ProjectItem, field *ProjectField, option *ProjectFieldOption) error { 391 | graphql := ` 392 | mutation($Project: ID!, $Item: ID!, $Field: ID!, $Option: String!) { 393 | updateProjectV2ItemFieldValue(input: {projectId: $Project, itemId: $Item, fieldId: $Field, value: {singleSelectOptionId: $Option}}) { 394 | clientMutationId 395 | } 396 | } 397 | ` 398 | _, err := c.GraphQLMutation(graphql, Vars{"Project": project.ID, "Item": item.ID, "Field": field.ID, "Option": option.ID}) 399 | return err 400 | } 401 | 402 | func (c *Client) DeleteProjectItem(project *Project, item *ProjectItem) error { 403 | graphql := ` 404 | mutation($Project: ID!, $Item: ID!) { 405 | deleteProjectV2Item(input: {projectId: $Project, itemId: $Item}) { 406 | clientMutationId 407 | } 408 | } 409 | ` 410 | _, err := c.GraphQLMutation(graphql, Vars{"Project": project.ID, "Item": item.ID}) 411 | return err 412 | } 413 | 414 | type Label struct { 415 | Name string 416 | Description string 417 | ID string 418 | Owner string 419 | Repo string 420 | } 421 | 422 | func toLabel(s *schema.Label) *Label { 423 | return &Label{ 424 | Name: s.Name, 425 | Description: s.Description, 426 | ID: string(s.Id), 427 | Owner: toOwner(&s.Repository.Owner), 428 | Repo: s.Repository.Name, 429 | } 430 | } 431 | 432 | type Discussion struct { 433 | Title string 434 | Number int 435 | Locked bool 436 | Closed bool 437 | ClosedAt time.Time 438 | Owner string 439 | Repo string 440 | Body string 441 | } 442 | 443 | func toAuthor(a *schema.Actor) string { 444 | if a != nil && a.Interface != nil { 445 | return a.Interface.GetLogin() 446 | } 447 | return "" 448 | } 449 | 450 | func toOwner(o *schema.RepositoryOwner) string { 451 | if o != nil && o.Interface != nil { 452 | return o.Interface.(interface{ GetLogin() string }).GetLogin() 453 | } 454 | return "" 455 | } 456 | 457 | func toDiscussion(s *schema.Discussion) *Discussion { 458 | return &Discussion{ 459 | Title: s.Title, 460 | Number: s.Number, 461 | Locked: s.Locked, 462 | Closed: s.Closed, 463 | ClosedAt: toTime(s.ClosedAt), 464 | Owner: toOwner(&s.Repository.Owner), 465 | Repo: s.Repository.Name, 466 | Body: s.Body, 467 | } 468 | } 469 | 470 | type Milestone struct { 471 | Title string 472 | ID string 473 | } 474 | 475 | func toMilestone(s *schema.Milestone) *Milestone { 476 | if s == nil { 477 | return nil 478 | } 479 | return &Milestone{ 480 | Title: s.Title, 481 | ID: string(s.Id), 482 | } 483 | } 484 | 485 | type Issue struct { 486 | ID string 487 | Title string 488 | Number int 489 | Closed bool 490 | ClosedAt time.Time 491 | CreatedAt time.Time 492 | LastEditedAt time.Time 493 | Labels []*Label 494 | Milestone *Milestone 495 | Author string 496 | Owner string 497 | Repo string 498 | Body string 499 | URL string 500 | } 501 | 502 | func toIssue(s *schema.Issue) *Issue { 503 | return &Issue{ 504 | ID: string(s.Id), 505 | Title: s.Title, 506 | Number: s.Number, 507 | Author: toAuthor(&s.Author), 508 | Closed: s.Closed, 509 | ClosedAt: toTime(s.ClosedAt), 510 | CreatedAt: toTime(s.CreatedAt), 511 | LastEditedAt: toTime(s.LastEditedAt), 512 | Owner: toOwner(&s.Repository.Owner), 513 | Repo: s.Repository.Name, 514 | Milestone: toMilestone(s.Milestone), 515 | Labels: apply(toLabel, s.Labels.Nodes), 516 | Body: s.Body, 517 | URL: string(s.Url), 518 | } 519 | } 520 | 521 | func (i *Issue) LabelByName(name string) *Label { 522 | for _, lab := range i.Labels { 523 | if lab.Name == name { 524 | return lab 525 | } 526 | } 527 | return nil 528 | } 529 | 530 | type IssueComment struct { 531 | ID string 532 | Author string 533 | Body string 534 | CreatedAt time.Time 535 | PublishedAt time.Time 536 | UpdatedAt time.Time 537 | URL string 538 | Issue int 539 | Owner string 540 | Repo string 541 | } 542 | 543 | func toIssueComment(s *schema.IssueComment) *IssueComment { 544 | return &IssueComment{ 545 | Author: toAuthor(&s.Author), 546 | Body: s.Body, 547 | CreatedAt: toTime(s.CreatedAt), 548 | ID: string(s.Id), 549 | PublishedAt: toTime(s.PublishedAt), 550 | UpdatedAt: toTime(s.UpdatedAt), 551 | URL: string(s.Url), 552 | Issue: s.Issue.GetNumber(), 553 | Owner: toOwner(&s.Repository.Owner), 554 | Repo: s.Repository.Name, 555 | } 556 | } 557 | 558 | type Repo struct { 559 | Owner string 560 | Repo string 561 | ID string 562 | } 563 | 564 | func (c *Client) Repo(org, repo string) (*Repo, error) { 565 | graphql := ` 566 | query($Org: String!, $Repo: String!) { 567 | repository(owner: $Org, name: $Repo) { 568 | id 569 | } 570 | } 571 | ` 572 | vars := Vars{"Org": org, "Repo": repo} 573 | q, err := c.GraphQLQuery(graphql, vars) 574 | if err != nil { 575 | return nil, err 576 | } 577 | return &Repo{org, repo, string(q.Repository.Id)}, nil 578 | } 579 | -------------------------------------------------------------------------------- /issuedb/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "database/sql" 9 | "encoding/json" 10 | "errors" 11 | "flag" 12 | "fmt" 13 | "io/ioutil" 14 | "log" 15 | "net/http" 16 | "net/url" 17 | "os" 18 | "strconv" 19 | "strings" 20 | "time" 21 | 22 | "rsc.io/dbstore" 23 | _ "rsc.io/sqlite" 24 | ) 25 | 26 | // TODO: pragma journal_mode=WAL 27 | 28 | // Database tables. DO NOT CHANGE. 29 | 30 | type Auth struct { 31 | Key string `dbstore:",key"` 32 | ClientID string 33 | ClientSecret string 34 | } 35 | 36 | type ProjectSync struct { 37 | Name string `dbstore:",key"` // "owner/repo" 38 | EventETag string 39 | EventID int64 40 | IssueDate string 41 | CommentDate string 42 | RefillID int64 43 | } 44 | 45 | type RawJSON struct { 46 | URL string `dbstore:",key"` 47 | Project string 48 | Issue int64 49 | Type string 50 | JSON []byte `dbstore:",blob"` 51 | Time string 52 | } 53 | 54 | var ( 55 | file = flag.String("f", os.Getenv("HOME")+"/githubissue.db", "database `file` to use") 56 | storage = new(dbstore.Storage) 57 | db *sql.DB 58 | auth Auth 59 | ) 60 | 61 | func usage() { 62 | fmt.Fprintf(os.Stderr, `usage: issuedb [-f db] command [args] 63 | 64 | Commands are: 65 | 66 | init (initialize new database) 67 | add (add new repository) 68 | sync (sync repositories) 69 | resync (full resync to catch very old events) 70 | 71 | The default database is $HOME/githubissue.db. 72 | `) 73 | os.Exit(2) 74 | } 75 | 76 | func main() { 77 | log.SetPrefix("issuedb: ") 78 | log.SetFlags(0) 79 | 80 | storage.Register(new(Auth)) 81 | storage.Register(new(ProjectSync)) 82 | storage.Register(new(RawJSON)) 83 | 84 | flag.Usage = usage 85 | flag.Parse() 86 | args := flag.Args() 87 | if len(args) == 0 { 88 | usage() 89 | } 90 | 91 | if args[0] == "init" { 92 | if len(args) != 3 { 93 | fmt.Fprintf(os.Stderr, "usage: issuedb [-f db] init clientid clientsecret\n") 94 | os.Exit(2) 95 | } 96 | _, err := os.Stat(*file) 97 | if err == nil { 98 | log.Fatalf("creating database: file %s already exists", *file) 99 | } 100 | db, err := sql.Open("sqlite3", *file) 101 | if err != nil { 102 | log.Fatalf("creating database: %v", err) 103 | } 104 | defer db.Close() 105 | if err := storage.CreateTables(db); err != nil { 106 | log.Fatalf("initializing database: %v", err) 107 | } 108 | auth = Auth{Key: "unauth", ClientID: args[1], ClientSecret: args[2]} 109 | if err := storage.Insert(db, &auth); err != nil { 110 | log.Fatal(err) 111 | } 112 | return 113 | } 114 | 115 | _, err := os.Stat(*file) 116 | if err != nil { 117 | log.Fatalf("opening database: %v", err) 118 | } 119 | db, err = sql.Open("sqlite3", *file) 120 | if err != nil { 121 | log.Fatalf("opening database: %v", err) 122 | } 123 | defer db.Close() 124 | 125 | auth.Key = "unauth" 126 | if err := storage.Read(db, &auth, "ALL"); err != nil { 127 | log.Fatalf("reading database: %v", err) 128 | } 129 | 130 | // TODO: Remove or deal with better. 131 | // This is here so that if we add new tables they get created in old databases. 132 | // But there is nothing to recreate or expand tables in old databases. 133 | 134 | switch args[0] { 135 | default: 136 | usage() 137 | 138 | case "add": 139 | if len(args) != 2 { 140 | fmt.Fprintf(os.Stderr, "usage: issuedb [-f db] add owner/repo\n") 141 | os.Exit(2) 142 | } 143 | var proj ProjectSync 144 | proj.Name = args[1] 145 | if err := storage.Read(db, &proj); err == nil { 146 | log.Fatalf("project %s already stored in database", proj.Name) 147 | } 148 | 149 | proj.Name = args[1] 150 | if err := storage.Insert(db, &proj); err != nil { 151 | log.Fatalf("adding project: %v", err) 152 | } 153 | return 154 | 155 | case "sync", "resync": 156 | var projects []ProjectSync 157 | if err := storage.Select(db, &projects, ""); err != nil { 158 | log.Fatalf("reading projects: %v", err) 159 | } 160 | for _, proj := range projects { 161 | if match(proj.Name, args[1:]) { 162 | doSync(&proj, args[0] == "resync") 163 | } 164 | } 165 | for _, arg := range args[1:] { 166 | if arg != didArg { 167 | log.Printf("unknown project: %s", arg) 168 | } 169 | } 170 | 171 | case "retime": 172 | retime() 173 | 174 | case "todo": 175 | var projects []ProjectSync 176 | if err := storage.Select(db, &projects, ""); err != nil { 177 | log.Fatalf("reading projects: %v", err) 178 | } 179 | for _, proj := range projects { 180 | if match(proj.Name, args[1:]) { 181 | todo(&proj) 182 | } 183 | } 184 | for _, arg := range args[1:] { 185 | if arg != didArg { 186 | log.Printf("unknown project: %s", arg) 187 | } 188 | } 189 | } 190 | } 191 | 192 | const didArg = "\x00" 193 | 194 | func match(name string, args []string) bool { 195 | if len(args) == 0 { 196 | return true 197 | } 198 | ok := false 199 | for i, arg := range args { 200 | if name == arg { 201 | args[i] = didArg 202 | ok = true 203 | } 204 | } 205 | return ok 206 | } 207 | 208 | func doSync(proj *ProjectSync, resync bool) { 209 | println("WOULD SYNC", proj.Name) 210 | syncIssues(proj) 211 | syncIssueComments(proj) 212 | if resync { 213 | syncIssueEvents(proj, 0, true) 214 | syncIssueEventsByIssue(proj) 215 | } else { 216 | syncIssueEvents(proj, 0, false) 217 | } 218 | } 219 | 220 | func syncIssueComments(proj *ProjectSync) { 221 | downloadByDate(proj, "/issues/comments", &proj.CommentDate, "CommentDate") 222 | } 223 | 224 | func syncIssues(proj *ProjectSync) { 225 | downloadByDate(proj, "/issues", &proj.IssueDate, "IssueDate") 226 | } 227 | 228 | func downloadByDate(proj *ProjectSync, api string, since *string, sinceName string) { 229 | values := url.Values{ 230 | "sort": {"updated"}, 231 | "direction": {"asc"}, 232 | "page": {"1"}, 233 | "per_page": {"100"}, 234 | } 235 | if api == "/issues" { 236 | values.Set("state", "all") 237 | } 238 | if api == "/issues/comments" { 239 | delete(values, "per_page") 240 | } 241 | if since != nil && *since != "" { 242 | values.Set("since", *since) 243 | } 244 | urlStr := "https://api.github.com/repos/" + proj.Name + api + "?" + values.Encode() 245 | 246 | err := downloadPages(urlStr, "", func(_ *http.Response, all []json.RawMessage) error { 247 | tx, err := db.Begin() 248 | if err != nil { 249 | return fmt.Errorf("starting db transaction: %v", err) 250 | } 251 | defer tx.Rollback() 252 | var last string 253 | for _, m := range all { 254 | var meta struct { 255 | URL string 256 | Updated string `json:"updated_at"` 257 | Number int64 // for /issues feed 258 | IssueURL string `json:"issue_url"` // for /issues/comments feed 259 | CreatedAt string `json:"created_at"` 260 | } 261 | if err := json.Unmarshal(m, &meta); err != nil { 262 | return fmt.Errorf("parsing message: %v", err) 263 | } 264 | if meta.Updated == "" { 265 | return fmt.Errorf("parsing message: no updated_at: %s", string(m)) 266 | } 267 | last = meta.Updated 268 | 269 | var raw RawJSON 270 | raw.URL = meta.URL 271 | raw.Project = proj.Name 272 | switch api { 273 | default: 274 | log.Fatalf("downloadByDate: unknown API: %v", api) 275 | case "/issues": 276 | raw.Issue = meta.Number 277 | case "/issues/comments": 278 | i := strings.LastIndex(meta.IssueURL, "/") 279 | n, err := strconv.ParseInt(meta.IssueURL[i+1:], 10, 64) 280 | if err != nil { 281 | log.Fatalf("cannot find issue number in /issues/comments API: %v", urlStr) 282 | } 283 | raw.Issue = n 284 | } 285 | raw.Type = api 286 | raw.JSON = m 287 | raw.Time = meta.CreatedAt 288 | if err := storage.Insert(tx, &raw); err != nil { 289 | return fmt.Errorf("writing JSON to database: %v", err) 290 | } 291 | } 292 | if since != nil { 293 | *since = last 294 | if err := storage.Write(tx, proj, sinceName); err != nil { 295 | return fmt.Errorf("updating database metadata: %v", err) 296 | } 297 | } 298 | if err := tx.Commit(); err != nil { 299 | return err 300 | } 301 | return nil 302 | }) 303 | 304 | if err != nil { 305 | log.Fatal(err) 306 | } 307 | } 308 | 309 | func syncIssueEvents(proj *ProjectSync, id int, short bool) { 310 | tx, err := db.Begin() 311 | if err != nil { 312 | log.Fatalf("starting db transaction: %v", err) 313 | } 314 | defer tx.Rollback() 315 | 316 | values := url.Values{ 317 | "client_id": {auth.ClientID}, 318 | "client_secret": {auth.ClientSecret}, 319 | "page": {"1"}, 320 | "per_page": {"100"}, 321 | } 322 | var api = "/issues/events" 323 | if id > 0 { 324 | api = fmt.Sprintf("/issues/%d/events", id) 325 | } 326 | urlStr := "https://api.github.com/repos/" + proj.Name + api + "?" + values.Encode() 327 | var ( 328 | firstID int64 329 | firstETag string 330 | ) 331 | done := errors.New("DONE") 332 | err = downloadPages(urlStr, proj.EventETag, func(resp *http.Response, all []json.RawMessage) error { 333 | for _, m := range all { 334 | var meta struct { 335 | ID int64 `json:"id"` 336 | URL string `json:"url"` 337 | Issue struct { 338 | Number int64 339 | } 340 | } 341 | if err := json.Unmarshal(m, &meta); err != nil { 342 | return fmt.Errorf("parsing message: %v", err) 343 | } 344 | if meta.ID == 0 { 345 | return fmt.Errorf("parsing message: no id: %s", string(m)) 346 | } 347 | println(meta.ID) 348 | if firstID == 0 { 349 | firstID = meta.ID 350 | firstETag = resp.Header.Get("Etag") 351 | } 352 | if id == 0 && (proj.EventID != 0 && meta.ID <= proj.EventID || short) { 353 | return done 354 | } 355 | 356 | var raw RawJSON 357 | raw.URL = meta.URL 358 | raw.Project = proj.Name 359 | raw.Type = "/issues/events" 360 | if id > 0 { 361 | raw.Issue = int64(id) 362 | } else { 363 | raw.Issue = meta.Issue.Number 364 | } 365 | raw.JSON = m 366 | if err := storage.Insert(tx, &raw); err != nil { 367 | return fmt.Errorf("writing JSON to database: %v", err) 368 | } 369 | } 370 | return nil 371 | }) 372 | if err == done { 373 | err = nil 374 | } 375 | if err != nil { 376 | if strings.Contains(err.Error(), "304 Not Modified") { 377 | return 378 | } 379 | log.Fatalf("syncing events: %v", err) 380 | } 381 | 382 | if id == 0 && firstID != 0 { 383 | proj.EventID = firstID 384 | proj.EventETag = firstETag 385 | if err := storage.Write(tx, proj, "EventID", "EventETag"); err != nil { 386 | log.Fatalf("updating database metadata: %v", err) 387 | } 388 | } 389 | 390 | if err := tx.Commit(); err != nil { 391 | log.Fatal(err) 392 | } 393 | } 394 | 395 | func syncIssueEventsByIssue(proj *ProjectSync) { 396 | rows, err := db.Query("select URL from RawJSON where Type = ? group by URL", "/issues") 397 | if err != nil { 398 | log.Fatal(err) 399 | } 400 | var ids []int 401 | suffix := "repos/" + proj.Name + "/issues/" 402 | for rows.Next() { 403 | var url string 404 | if err := rows.Scan(&url); err != nil { 405 | log.Fatal(err) 406 | } 407 | i := strings.LastIndex(url, "/") 408 | if !strings.HasSuffix(url[:i+1], suffix) { 409 | continue 410 | } 411 | id, err := strconv.Atoi(url[i+1:]) 412 | if err != nil { 413 | log.Fatal(url, err) 414 | } 415 | ids = append(ids, id) 416 | } 417 | for _, id := range ids { 418 | println("ID", id) 419 | syncIssueEvents(proj, id, false) 420 | } 421 | } 422 | 423 | func downloadPages(url, etag string, do func(*http.Response, []json.RawMessage) error) error { 424 | nfail := 0 425 | for n := 0; url != ""; n++ { 426 | again: 427 | println("URL:", url) 428 | req, err := http.NewRequest("GET", url, nil) 429 | if err != nil { 430 | return err 431 | } 432 | if etag != "" { 433 | req.Header.Set("If-None-Match", etag) 434 | } 435 | req.SetBasicAuth(auth.ClientID, auth.ClientSecret) 436 | resp, err := http.DefaultClient.Do(req) 437 | if err != nil { 438 | return err 439 | } 440 | //println("RESP:", js(resp.Header)) 441 | 442 | data, err := ioutil.ReadAll(resp.Body) 443 | if err != nil { 444 | return fmt.Errorf("reading body: %v", err) 445 | } 446 | if resp.StatusCode != 200 { 447 | if resp.StatusCode == 403 { 448 | if resp.Header.Get("X-Ratelimit-Remaining") == "0" { 449 | n, _ := strconv.Atoi(resp.Header.Get("X-Ratelimit-Reset")) 450 | if n > 0 { 451 | t := time.Unix(int64(n), 0) 452 | println("RATELIMIT", t.String()) 453 | time.Sleep(t.Sub(time.Now()) + 1*time.Minute) 454 | goto again 455 | } 456 | } 457 | } 458 | if resp.StatusCode == 500 || resp.StatusCode == 502 { 459 | nfail++ 460 | if nfail < 2 { 461 | println("REPEAT:", resp.Status, string(data)) 462 | time.Sleep(time.Duration(nfail) * 2 * time.Second) 463 | goto again 464 | } 465 | } 466 | return fmt.Errorf("%s\n%s", resp.Status, data) 467 | } 468 | checkRateLimit(resp) 469 | 470 | var all []json.RawMessage 471 | if err := json.Unmarshal(data, &all); err != nil { 472 | return fmt.Errorf("parsing body: %v", err) 473 | } 474 | println("GOT", len(all), "messages") 475 | 476 | if err := do(resp, all); err != nil { 477 | return err 478 | } 479 | 480 | url = findNext(resp.Header.Get("Link")) 481 | } 482 | return nil 483 | } 484 | 485 | func findNext(link string) string { 486 | for link != "" { 487 | link = strings.TrimSpace(link) 488 | if !strings.HasPrefix(link, "<") { 489 | break 490 | } 491 | i := strings.Index(link, ">") 492 | if i < 0 { 493 | break 494 | } 495 | linkURL := link[1:i] 496 | link = strings.TrimSpace(link[i+1:]) 497 | for strings.HasPrefix(link, ";") { 498 | link = strings.TrimSpace(link[1:]) 499 | i := strings.Index(link, ";") 500 | j := strings.Index(link, ",") 501 | if i < 0 || j >= 0 && j < i { 502 | i = j 503 | } 504 | if i < 0 { 505 | i = len(link) 506 | } 507 | attr := strings.TrimSpace(link[:i]) 508 | if attr == `rel="next"` { 509 | return linkURL 510 | } 511 | link = link[i:] 512 | } 513 | if !strings.HasPrefix(link, ",") { 514 | break 515 | } 516 | link = strings.TrimSpace(link[1:]) 517 | } 518 | return "" 519 | } 520 | 521 | func checkRateLimit(resp *http.Response) { 522 | // TODO 523 | } 524 | 525 | func js(x interface{}) string { 526 | data, err := json.MarshalIndent(x, "", "\t") 527 | if err != nil { 528 | return "ERROR: " + err.Error() 529 | } 530 | return string(data) 531 | } 532 | 533 | type ghIssueEvent struct { 534 | // NOTE: Issue field is not present when downloading for a specific issue, 535 | // only in the master feed for the whole repo. So do not add it here. 536 | Actor struct { 537 | Login string `json:"login"` 538 | } `json:"actor"` 539 | Event string `json:"event"` 540 | Labels []struct { 541 | Name string `json:"name"` 542 | } `json:"labels"` 543 | LockReason string `json:"lock_reason"` 544 | CreatedAt string `json:"created_at"` 545 | CommitID string `json:"commit_id"` 546 | Assigner struct { 547 | Login string `json:"login"` 548 | } `json:"assigner"` 549 | Assignees []struct { 550 | Login string `json:"login"` 551 | } `json:"assignees"` 552 | Milestone struct { 553 | Title string `json:"title"` 554 | } `json:"milestone"` 555 | Rename struct { 556 | From string `json:"from"` 557 | To string `json:"to"` 558 | } `json:"rename"` 559 | } 560 | 561 | type ghIssueComment struct { 562 | IssueURL string `json:"issue_url"` 563 | HTMLURL string `json:"html_url"` 564 | User struct { 565 | Login string `json:"login"` 566 | } `json:"user"` 567 | CreatedAt string `json:"created_at"` 568 | UpdatedAt string `json:"updated_at"` 569 | Body string `json:"body"` 570 | } 571 | 572 | type ghIssue struct { 573 | URL string `json:"url"` 574 | HTMLURL string `json:"html_url"` 575 | User struct { 576 | Login string `json:"login"` 577 | } `json:"user"` 578 | Title string `json:"title"` 579 | CreatedAt string `json:"created_at"` 580 | UpdatedAt string `json:"updated_at"` 581 | ClosedAt string `json:"closed_at"` 582 | Body string `json:"body"` 583 | Assignees []struct { 584 | Login string `json:"login"` 585 | } `json:"assignees"` 586 | Milestone struct { 587 | Title string `json:"title"` 588 | } `json:"milestone"` 589 | State string `json:"state"` 590 | PullRequest *struct{} `json:"pull_request"` 591 | Locked bool 592 | ActiveLockReason string `json:"active_lock_reason"` 593 | Labels []struct { 594 | Name string `json:"name"` 595 | } `json:"labels"` 596 | } 597 | 598 | func retime() { 599 | last := "" 600 | for { 601 | var all []RawJSON 602 | if err := storage.Select(db, &all, "where URL > ? and Time = ? order by URL asc limit 100", last, ""); err != nil { 603 | log.Fatalf("sql: %v", err) 604 | } 605 | if len(all) == 0 { 606 | break 607 | } 608 | println("GOT", len(all), all[0].URL, all[0].Type, all[len(all)-1].URL, all[len(all)-1].Type) 609 | tx, err := db.Begin() 610 | if err != nil { 611 | log.Fatal(err) 612 | } 613 | for _, m := range all { 614 | var meta struct { 615 | CreatedAt string `json:"created_at"` 616 | } 617 | if err := json.Unmarshal(m.JSON, &meta); err != nil { 618 | log.Fatal(err) 619 | } 620 | if meta.CreatedAt == "" { 621 | log.Fatalf("missing created_at: %s", m.JSON) 622 | } 623 | tm, err := time.Parse(time.RFC3339, meta.CreatedAt) 624 | if err != nil { 625 | log.Fatalf("parse: %v", err) 626 | } 627 | if _, err := tx.Exec("update RawJSON set Time = ? where URL = ?", tm.UTC().Format(time.RFC3339Nano), m.URL); err != nil { 628 | log.Fatal(err) 629 | } 630 | last = m.URL 631 | } 632 | if err := tx.Commit(); err != nil { 633 | log.Fatal(err) 634 | } 635 | } 636 | } 637 | --------------------------------------------------------------------------------