@@ -10,12 +10,16 @@ import (
10
10
"fmt"
11
11
"io"
12
12
"io/fs"
13
+ "regexp"
14
+ "slices"
15
+ "strconv"
13
16
"strings"
14
17
"time"
15
18
16
19
"golang.org/x/build/gerrit"
17
20
"golang.org/x/build/maintner"
18
21
"golang.org/x/build/maintner/godata"
22
+ "golang.org/x/exp/maps"
19
23
)
20
24
21
25
type ToDo struct {
@@ -34,7 +38,7 @@ func todo(w io.Writer, fsys fs.FS, prevRelDate time.Time) error {
34
38
return err
35
39
}
36
40
if ! prevRelDate .IsZero () {
37
- if err := todosFromRelnoteCLs (prevRelDate , add ); err != nil {
41
+ if err := todosFromCLs (prevRelDate , add ); err != nil {
38
42
return err
39
43
}
40
44
}
@@ -77,7 +81,7 @@ func todosFromFile(dir fs.FS, filename string, add func(ToDo)) error {
77
81
return scan .Err ()
78
82
}
79
83
80
- func todosFromRelnoteCLs (cutoff time.Time , add func (ToDo )) error {
84
+ func todosFromCLs (cutoff time.Time , add func (ToDo )) error {
81
85
ctx := context .Background ()
82
86
// The maintner corpus doesn't track inline comments. See go.dev/issue/24863.
83
87
// So we need to use a Gerrit API client to fetch them instead. If maintner starts
@@ -91,6 +95,7 @@ func todosFromRelnoteCLs(cutoff time.Time, add func(ToDo)) error {
91
95
if err != nil {
92
96
return err
93
97
}
98
+ gh := corpus .GitHub ().Repo ("golang" , "go" )
94
99
return corpus .Gerrit ().ForeachProjectUnsorted (func (gp * maintner.GerritProject ) error {
95
100
if gp .Server () != "go.googlesource.com" {
96
101
return nil
@@ -107,30 +112,151 @@ func todosFromRelnoteCLs(cutoff time.Time, add func(ToDo)) error {
107
112
// Was in a previous release; not for this one.
108
113
return nil
109
114
}
110
- // TODO(jba): look for accepted proposals that don't have release notes.
115
+ // Add a TODO if the CL has a "RELNOTE=" comment.
116
+ // These are deprecated, but we look for them just in case.
111
117
if _ , ok := matchedCLs [int (cl .Number )]; ok {
112
- comments , err := gerritClient .ListChangeComments (context .Background (), fmt .Sprint (cl .Number ))
113
- if err != nil {
118
+ if err := todoFromRelnote (ctx , cl , gerritClient , add ); err != nil {
114
119
return err
115
120
}
116
- if rn := clRelNote (cl , comments ); rn != "" {
117
- if rn == "yes" || rn == "y" {
118
- rn = "UNKNOWN"
119
- }
120
- add (ToDo {
121
- message : "TODO:" + rn ,
122
- provenance : fmt .Sprintf ("RELNOTE comment in https://go.dev/cl/%d" , cl .Number ),
123
- })
124
- }
125
121
}
122
+ // Add a TODO if the CL refers to an accepted proposal.
123
+ todoFromProposal (ctx , cl , gh , add )
126
124
return nil
127
125
})
128
126
})
129
127
}
130
128
129
+ func todoFromRelnote (ctx context.Context , cl * maintner.GerritCL , gc * gerrit.Client , add func (ToDo )) error {
130
+ comments , err := gc .ListChangeComments (ctx , fmt .Sprint (cl .Number ))
131
+ if err != nil {
132
+ return err
133
+ }
134
+ if rn := clRelNote (cl , comments ); rn != "" {
135
+ if rn == "yes" || rn == "y" {
136
+ rn = "UNKNOWN"
137
+ }
138
+ add (ToDo {
139
+ message : "TODO:" + rn ,
140
+ provenance : fmt .Sprintf ("RELNOTE comment in https://go.dev/cl/%d" , cl .Number ),
141
+ })
142
+ }
143
+ return nil
144
+ }
145
+
146
+ func todoFromProposal (ctx context.Context , cl * maintner.GerritCL , gh * maintner.GitHubRepo , add func (ToDo )) {
147
+ for _ , num := range issueNumbers (cl ) {
148
+ // TODO(jba): look for CL references in existing release notes to avoid adding TODOs for
149
+ // CLs that have already been documented.
150
+ if issue := gh .Issue (num ); issue != nil && hasLabel (issue , "Proposal-Accepted" ) {
151
+ // Add a TODO for all issues, regardless of when or whether they are closed.
152
+ // Any work on an accepted proposal is potentially worthy of a release note.
153
+ add (ToDo {
154
+ message : fmt .Sprintf ("TODO: accepted proposal https://go.dev/issue/%d" , num ),
155
+ provenance : fmt .Sprintf ("https://go.dev/cl/%d" , cl .Number ),
156
+ })
157
+ }
158
+ }
159
+ }
160
+
161
+ func hasLabel (issue * maintner.GitHubIssue , label string ) bool {
162
+ for _ , l := range issue .Labels {
163
+ if l .Name == label {
164
+ return true
165
+ }
166
+ }
167
+ return false
168
+ }
169
+
170
+ // findCLsWithRelNote finds CLs that contain a RELNOTE marker by
171
+ // using a Gerrit API client. Returned map is keyed by CL number.
172
+ func findCLsWithRelNote (client * gerrit.Client , since time.Time ) (map [int ]* gerrit.ChangeInfo , error ) {
173
+ // Gerrit search operators are documented at
174
+ // https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators.
175
+ query := fmt .Sprintf (`status:merged branch:master since:%s (comment:"RELNOTE" OR comment:"RELNOTES")` ,
176
+ since .Format ("2006-01-02" ))
177
+ cs , err := client .QueryChanges (context .Background (), query )
178
+ if err != nil {
179
+ return nil , err
180
+ }
181
+ m := make (map [int ]* gerrit.ChangeInfo ) // CL Number → CL.
182
+ for _ , c := range cs {
183
+ m [c .ChangeNumber ] = c
184
+ }
185
+ return m , nil
186
+ }
187
+
188
+ // clRelNote extracts a RELNOTE note from a Gerrit CL commit
189
+ // message and any inline comments. If there isn't a RELNOTE
190
+ // note, it returns the empty string.
191
+ func clRelNote (cl * maintner.GerritCL , comments map [string ][]gerrit.CommentInfo ) string {
192
+ msg := cl .Commit .Msg
193
+ if strings .Contains (msg , "RELNOTE" ) {
194
+ return parseRelNote (msg )
195
+ }
196
+ // Since July 2020, Gerrit UI has replaced top-level comments
197
+ // with patchset-level inline comments, so don't bother looking
198
+ // for RELNOTE= in cl.Messages—there won't be any. Instead, do
199
+ // look through all inline comments that we got via Gerrit API.
200
+ for _ , cs := range comments {
201
+ for _ , c := range cs {
202
+ if strings .Contains (c .Message , "RELNOTE" ) {
203
+ return parseRelNote (c .Message )
204
+ }
205
+ }
206
+ }
207
+ return ""
208
+ }
209
+
210
+ var relNoteRx = regexp .MustCompile (`RELNOTES?=(.+)` )
211
+
212
+ // parseRelNote parses a RELNOTE annotation from the string s.
213
+ // It returns the empty string if no such annotation exists.
214
+ func parseRelNote (s string ) string {
215
+ m := relNoteRx .FindStringSubmatch (s )
216
+ if m == nil {
217
+ return ""
218
+ }
219
+ return m [1 ]
220
+ }
221
+
222
+ var numbersRE = regexp .MustCompile (`(?m)(?:^|\s|golang/go)#([0-9]{3,})` )
223
+ var golangGoNumbersRE = regexp .MustCompile (`(?m)golang/go#([0-9]{3,})` )
224
+
225
+ // issueNumbers returns the golang/go issue numbers referred to by the CL.
226
+ func issueNumbers (cl * maintner.GerritCL ) []int32 {
227
+ var re * regexp.Regexp
228
+ if cl .Project .Project () == "go" {
229
+ re = numbersRE
230
+ } else {
231
+ re = golangGoNumbersRE
232
+ }
233
+
234
+ var list []int32
235
+ for _ , s := range re .FindAllStringSubmatch (cl .Commit .Msg , - 1 ) {
236
+ if n , err := strconv .Atoi (s [1 ]); err == nil && n < 1e9 {
237
+ list = append (list , int32 (n ))
238
+ }
239
+ }
240
+ // Remove duplicates.
241
+ slices .Sort (list )
242
+ return slices .Compact (list )
243
+ }
244
+
131
245
func writeToDos (w io.Writer , todos []ToDo ) error {
246
+ // Group TODOs with the same message. This simplifies the output when a single
247
+ // issue is implemented by multiple CLs.
248
+ byMessage := map [string ][]ToDo {}
132
249
for _ , td := range todos {
133
- if _ , err := fmt .Fprintf (w , "%s (from %s)\n " , td .message , td .provenance ); err != nil {
250
+ byMessage [td .message ] = append (byMessage [td .message ], td )
251
+ }
252
+ msgs := maps .Keys (byMessage )
253
+ slices .Sort (msgs ) // for deterministic output
254
+ for _ , msg := range msgs {
255
+ var provs []string
256
+ for _ , td := range byMessage [msg ] {
257
+ provs = append (provs , td .provenance )
258
+ }
259
+ if _ , err := fmt .Fprintf (w , "%s (from %s)\n " , msg , strings .Join (provs , ", " )); err != nil {
134
260
return err
135
261
}
136
262
}
0 commit comments