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  `))