golang.org/x/build@v0.0.0-20240506185731-218518f32b70/devapp/owners/owners.go (about) 1 // Copyright 2018 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 owners 6 7 import ( 8 "bytes" 9 "encoding/json" 10 "fmt" 11 "html/template" 12 "log" 13 "net/http" 14 "strings" 15 "sync" 16 17 "golang.org/x/build/repos" 18 ) 19 20 type Owner struct { 21 // GitHubUsername is a GitHub user name or team name. 22 GitHubUsername string `json:"githubUsername"` 23 GerritEmail string `json:"gerritEmail"` 24 } 25 26 type Entry struct { 27 Primary []Owner `json:"primary"` 28 Secondary []Owner `json:"secondary,omitempty"` 29 } 30 31 type displayEntry struct { 32 Primary []Owner 33 Secondary []Owner 34 GerritURL string 35 } 36 37 type Request struct { 38 Payload struct { 39 // Paths is a set of relative paths rooted at go.googlesource.com, 40 // where the first path component refers to the repository name, 41 // while the rest refers to a path within that repository. 42 // 43 // For instance, a path like go/src/runtime/trace/trace.go refers 44 // to the repository at go.googlesource.com/go, and the path 45 // src/runtime/trace/trace.go within that repository. 46 // 47 // A request with Paths set will return the owner entry 48 // for the deepest part of each path that it has information 49 // on. 50 // 51 // For example, the path go/src/runtime/trace/trace.go will 52 // match go/src/runtime/trace if there exist entries for both 53 // go/src/runtime and go/src/runtime/trace. 54 // 55 // Must be empty if All is true. 56 Paths []string `json:"paths"` 57 58 // All indicates that the response must contain every available 59 // entry about code owners. 60 // 61 // If All is true, Paths must be empty. 62 All bool `json:"all"` 63 } `json:"payload"` 64 Version int `json:"v"` // API version 65 } 66 67 type Response struct { 68 Payload struct { 69 Entries map[string]*Entry `json:"entries"` // paths in request -> Entry 70 } `json:"payload"` 71 Error string `json:"error,omitempty"` 72 } 73 74 // match takes a path consisting of the repo name and full path of a file or 75 // directory within that repo and returns the deepest Entry match in the file 76 // hierarchy for the given resource. 77 func match(path string) *Entry { 78 var deepestPath string 79 for p := range entries { 80 if hasPathPrefix(path, p) && len(p) > len(deepestPath) { 81 deepestPath = p 82 } 83 } 84 return entries[deepestPath] 85 } 86 87 // hasPathPrefix reports whether the slash-separated path s 88 // begins with the elements in prefix. 89 // 90 // Copied from go/src/cmd/go/internal/str.HasPathPrefix. 91 func hasPathPrefix(s, prefix string) bool { 92 if len(s) == len(prefix) { 93 return s == prefix 94 } 95 if prefix == "" { 96 return true 97 } 98 if len(s) > len(prefix) { 99 if prefix[len(prefix)-1] == '/' || s[len(prefix)] == '/' { 100 return s[:len(prefix)] == prefix 101 } 102 } 103 return false 104 } 105 106 // Handler takes one or more paths and returns a map of each to a matching 107 // Entry struct. If no Entry is matched for the path, the value for the key 108 // is nil. 109 func Handler(w http.ResponseWriter, r *http.Request) { 110 w.Header().Set("Access-Control-Allow-Origin", "*") 111 w.Header().Set("Content-Type", "application/json") 112 113 switch r.Method { 114 case "GET": 115 serveIndex(w, r) 116 return 117 case "POST": 118 var req Request 119 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 120 jsonError(w, "unable to decode request", http.StatusBadRequest) 121 // TODO: increment expvar for monitoring. 122 log.Printf("unable to decode owners request: %v", err) 123 return 124 } 125 126 if len(req.Payload.Paths) > 0 && req.Payload.All { 127 jsonError(w, "paths must be empty when all is true", http.StatusBadRequest) 128 // TODO: increment expvar for monitoring. 129 log.Printf("invalid request: paths is non-empty but all is true") 130 return 131 } 132 133 var resp Response 134 if req.Payload.All { 135 resp.Payload.Entries = entries 136 } else { 137 resp.Payload.Entries = make(map[string]*Entry) 138 for _, p := range req.Payload.Paths { 139 resp.Payload.Entries[p] = match(p) 140 } 141 } 142 // resp.Payload.Entries must not be mutated because it contains 143 // references to the global "entries" value. 144 145 var buf bytes.Buffer 146 if err := json.NewEncoder(&buf).Encode(resp); err != nil { 147 jsonError(w, "unable to encode response", http.StatusInternalServerError) 148 // TODO: increment expvar for monitoring. 149 log.Printf("unable to encode owners response: %v", err) 150 return 151 } 152 w.Write(buf.Bytes()) 153 case "OPTIONS": 154 // Likely a CORS preflight request; leave resp.Payload empty. 155 default: 156 jsonError(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 157 return 158 } 159 } 160 161 func jsonError(w http.ResponseWriter, text string, code int) { 162 w.WriteHeader(code) 163 var buf bytes.Buffer 164 if err := json.NewEncoder(&buf).Encode(Response{Error: text}); err != nil { 165 // TODO: increment expvar for monitoring. 166 log.Printf("unable to encode error response: %v", err) 167 return 168 } 169 w.Write(buf.Bytes()) 170 } 171 172 // TranslatePathForIssues takes a path for a package based on go.googlesource.com 173 // and translates it into a form that aligns more closely with the issue 174 // tracker. 175 // 176 // Specifically, Go standard library packages lose the go/src prefix, 177 // repositories with a golang.org/x/ import path get the x/ prefix, 178 // and all other paths are left as-is (this includes e.g. domains). 179 func TranslatePathForIssues(path string) string { 180 // Check if it's in the standard library, in which case, 181 // drop the prefix. 182 if strings.HasPrefix(path, "go/src/") { 183 return path[len("go/src/"):] 184 } 185 186 // Check if it's some other path in the main repo, in which case, 187 // drop the go/ prefix. 188 if strings.HasPrefix(path, "go/") { 189 return path[len("go/"):] 190 } 191 192 // Check if it's a golang.org/x/ repository, and if so add an x/ prefix. 193 firstComponent := path 194 i := strings.IndexRune(path, '/') 195 if i > 0 { 196 firstComponent = path[:i] 197 } 198 if _, ok := repos.ByImportPath["golang.org/x/"+firstComponent]; ok { 199 return "x/" + path 200 } 201 202 // None of the above was true, so just leave it untouched. 203 return path 204 } 205 206 // formatEntries returns an entries map adjusted for better readability on 207 // https://dev.golang.org/owners. 208 func formatEntries(entries map[string]*Entry) (map[string]*displayEntry, error) { 209 tm := make(map[string]*displayEntry) 210 for path, entry := range entries { 211 tPath := TranslatePathForIssues(path) 212 if _, ok := tm[tPath]; ok { 213 return nil, fmt.Errorf("path translation of %q creates a duplicate entry %q", path, tPath) 214 } 215 tm[tPath] = &displayEntry{ 216 Primary: entry.Primary, 217 Secondary: entry.Secondary, 218 GerritURL: gerritURL(path, tPath), 219 } 220 } 221 return tm, nil 222 } 223 224 func gerritURL(path, tPath string) string { 225 var project string 226 var dir string 227 if strings.HasPrefix(path, "go/") { 228 project = "go" 229 dir = tPath 230 } else if strings.HasPrefix(tPath, "x/") { 231 parts := strings.SplitN(tPath, "/", 3) 232 project = parts[1] 233 if len(parts) == 3 { 234 dir = parts[2] 235 } 236 } else { 237 return "" 238 } 239 url := "https://go-review.googlesource.com/q/project:" + project 240 if dir != "" { 241 url += "+dir:" + dir 242 } 243 return url 244 } 245 246 // ownerData is passed to the Template, which produces two tables. 247 type ownerData struct { 248 Paths map[string]*displayEntry 249 ArchOSes map[string]*displayEntry 250 } 251 252 func serveIndex(w http.ResponseWriter, _ *http.Request) { 253 w.Header().Set("Content-Type", "text/html; charset=utf-8") 254 255 indexCache.once.Do(func() { 256 paths, err := formatEntries(entries) 257 if err != nil { 258 indexCache.err = err 259 return 260 } 261 262 archOses, err := formatEntries(archOses) 263 if err != nil { 264 indexCache.err = err 265 return 266 } 267 268 displayEntries := ownerData{paths, archOses} 269 270 var buf bytes.Buffer 271 indexCache.err = indexTmpl.Execute(&buf, displayEntries) 272 indexCache.html = buf.Bytes() 273 }) 274 if indexCache.err != nil { 275 log.Printf("unable to serve index page HTML: %v", indexCache.err) 276 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 277 return 278 } 279 w.Write(indexCache.html) 280 } 281 282 // indexCache is a cache of the owners index page HTML. 283 // 284 // As long as the owners are defined at package initialization time 285 // and not modified at runtime, the HTML doesn't change per request. 286 var indexCache struct { 287 once sync.Once 288 html []byte // Page HTML rendered by indexTmpl. 289 err error 290 } 291 292 var indexTmpl = template.Must(template.New("index").Funcs(template.FuncMap{ 293 "githubURL": func(githubUsername string) string { 294 if i := strings.Index(githubUsername, "/"); i != -1 { 295 // A GitHub team like "{org}/{team}". 296 org, team := githubUsername[:i], githubUsername[i+len("/"):] 297 return "https://github.com/orgs/" + org + "/teams/" + team 298 } 299 return "https://github.com/" + githubUsername 300 }, 301 }).Parse(`<!DOCTYPE html> 302 <html lang="en"> 303 <title>Go Code Owners</title> 304 <meta name=viewport content="width=device-width, initial-scale=1"> 305 <style> 306 * { 307 box-sizing: border-box; 308 margin: 0; 309 padding: 0; 310 } 311 body { 312 font-family: sans-serif; 313 margin: 1rem 1.5rem; 314 } 315 .header { 316 color: #666; 317 font-size: 90%; 318 margin-bottom: 1rem; 319 } 320 .table-header { 321 font-weight: bold; 322 position: sticky; 323 top: 0; 324 } 325 .table-header, 326 .entry { 327 background-color: #fff; 328 border-bottom: 1px solid #ddd; 329 display: flex; 330 flex-wrap: wrap; 331 justify-content: space-between; 332 margin: .15rem 0; 333 padding: .15rem 0; 334 } 335 .path, 336 .primary, 337 .secondary { 338 flex-basis: 33.3%; 339 } 340 </style> 341 <header class="header"> 342 <p>Reviews are automatically assigned to primary owners.</p> 343 <p>Alter these entries at 344 <a href="https://go.googlesource.com/build/+/master/devapp/owners" 345 target="_blank" rel="noopener">golang.org/x/build/devapp/owners</a></p> 346 </header> 347 <main> 348 <div class="table-header"> 349 <span class="path">Path</span> 350 <span class="primary">Primaries</span> 351 <span class="secondary">Secondaries</span> 352 </div> 353 {{range $path, $entry := .Paths}} 354 <div class="entry"> 355 <span class="path"> 356 {{if $entry.GerritURL}}<a href="{{$entry.GerritURL}}" target="_blank" rel="noopener">{{end}} 357 {{$path}} 358 {{if $entry.GerritURL}}</a>{{end}} 359 </span> 360 <span class="primary"> 361 {{range .Primary}} 362 <a href="{{githubURL .GitHubUsername}}" target="_blank" rel="noopener">@{{.GitHubUsername}}</a> 363 {{end}} 364 </span> 365 <span class="secondary"> 366 {{range .Secondary}} 367 <a href="{{githubURL .GitHubUsername}}" target="_blank" rel="noopener">@{{.GitHubUsername}}</a> 368 {{end}} 369 </span> 370 </div> 371 {{end}} 372 <div class="table-header"> 373 <span class="path">Arch/OS</span> 374 <span class="primary">Primaries</span> 375 <span class="secondary">Secondaries</span> 376 </div> 377 {{range $path, $entry := .ArchOSes}} 378 <div class="entry"> 379 <span class="path">{{$path}}</span> 380 <span class="primary"> 381 {{range .Primary}} 382 <a href="{{githubURL .GitHubUsername}}" target="_blank" rel="noopener">@{{.GitHubUsername}}</a> 383 {{end}} 384 </span> 385 <span class="secondary"> 386 {{range .Secondary}} 387 <a href="{{githubURL .GitHubUsername}}" target="_blank" rel="noopener">@{{.GitHubUsername}}</a> 388 {{end}} 389 </span> 390 </div> 391 {{end}} 392 </main> 393 `))