blob: 47cecc48fcd6b42353c781f7f84a543351369f87 [file] [log] [blame]
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package owners
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"strings"
"sync"
"golang.org/x/build/repos"
)
type Owner struct {
// GitHubUsername is a GitHub user name or team name.
GitHubUsername string `json:"githubUsername"`
GerritEmail string `json:"gerritEmail"`
}
type Entry struct {
Primary []Owner `json:"primary"`
Secondary []Owner `json:"secondary,omitempty"`
}
type displayEntry struct {
Primary []Owner
Secondary []Owner
GerritURL string
}
type Request struct {
Payload struct {
// Paths is a set of relative paths rooted at go.googlesource.com,
// where the first path component refers to the repository name,
// while the rest refers to a path within that repository.
//
// For instance, a path like go/src/runtime/trace/trace.go refers
// to the repository at go.googlesource.com/go, and the path
// src/runtime/trace/trace.go within that repository.
//
// A request with Paths set will return the owner entry
// for the deepest part of each path that it has information
// on.
//
// For example, the path go/src/runtime/trace/trace.go will
// match go/src/runtime/trace if there exist entries for both
// go/src/runtime and go/src/runtime/trace.
//
// Must be empty if All is true.
Paths []string `json:"paths"`
// All indicates that the response must contain every available
// entry about code owners.
//
// If All is true, Paths must be empty.
All bool `json:"all"`
} `json:"payload"`
Version int `json:"v"` // API version
}
type Response struct {
Payload struct {
Entries map[string]*Entry `json:"entries"` // paths in request -> Entry
} `json:"payload"`
Error string `json:"error,omitempty"`
}
// match takes a path consisting of the repo name and full path of a file or
// directory within that repo and returns the deepest Entry match in the file
// hierarchy for the given resource.
func match(path string) *Entry {
var deepestPath string
for p := range entries {
if hasPathPrefix(path, p) && len(p) > len(deepestPath) {
deepestPath = p
}
}
return entries[deepestPath]
}
// hasPathPrefix reports whether the slash-separated path s
// begins with the elements in prefix.
//
// Copied from go/src/cmd/go/internal/str.HasPathPrefix.
func hasPathPrefix(s, prefix string) bool {
if len(s) == len(prefix) {
return s == prefix
}
if prefix == "" {
return true
}
if len(s) > len(prefix) {
if prefix[len(prefix)-1] == '/' || s[len(prefix)] == '/' {
return s[:len(prefix)] == prefix
}
}
return false
}
// Handler takes one or more paths and returns a map of each to a matching
// Entry struct. If no Entry is matched for the path, the value for the key
// is nil.
func Handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case "GET":
serveIndex(w, r)
return
case "POST":
var req Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "unable to decode request", http.StatusBadRequest)
// TODO: increment expvar for monitoring.
log.Printf("unable to decode owners request: %v", err)
return
}
if len(req.Payload.Paths) > 0 && req.Payload.All {
jsonError(w, "paths must be empty when all is true", http.StatusBadRequest)
// TODO: increment expvar for monitoring.
log.Printf("invalid request: paths is non-empty but all is true")
return
}
var resp Response
if req.Payload.All {
resp.Payload.Entries = entries
} else {
resp.Payload.Entries = make(map[string]*Entry)
for _, p := range req.Payload.Paths {
resp.Payload.Entries[p] = match(p)
}
}
// resp.Payload.Entries must not be mutated because it contains
// references to the global "entries" value.
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(resp); err != nil {
jsonError(w, "unable to encode response", http.StatusInternalServerError)
// TODO: increment expvar for monitoring.
log.Printf("unable to encode owners response: %v", err)
return
}
w.Write(buf.Bytes())
case "OPTIONS":
// Likely a CORS preflight request; leave resp.Payload empty.
default:
jsonError(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
}
func jsonError(w http.ResponseWriter, text string, code int) {
w.WriteHeader(code)
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(Response{Error: text}); err != nil {
// TODO: increment expvar for monitoring.
log.Printf("unable to encode error response: %v", err)
return
}
w.Write(buf.Bytes())
}
// TranslatePathForIssues takes a path for a package based on go.googlesource.com
// and translates it into a form that aligns more closely with the issue
// tracker.
//
// Specifically, Go standard library packages lose the go/src prefix,
// repositories with a golang.org/x/ import path get the x/ prefix,
// and all other paths are left as-is (this includes e.g. domains).
func TranslatePathForIssues(path string) string {
// Check if it's in the standard library, in which case,
// drop the prefix.
if strings.HasPrefix(path, "go/src/") {
return path[len("go/src/"):]
}
// Check if it's some other path in the main repo, in which case,
// drop the go/ prefix.
if strings.HasPrefix(path, "go/") {
return path[len("go/"):]
}
// Check if it's a golang.org/x/ repository, and if so add an x/ prefix.
firstComponent := path
i := strings.IndexRune(path, '/')
if i > 0 {
firstComponent = path[:i]
}
if _, ok := repos.ByImportPath["golang.org/x/"+firstComponent]; ok {
return "x/" + path
}
// None of the above was true, so just leave it untouched.
return path
}
// formatEntries returns an entries map adjusted for better readability on
// https://dev.golang.org/owners.
func formatEntries(entries map[string]*Entry) (map[string]*displayEntry, error) {
tm := make(map[string]*displayEntry)
for path, entry := range entries {
tPath := TranslatePathForIssues(path)
if _, ok := tm[tPath]; ok {
return nil, fmt.Errorf("path translation of %q creates a duplicate entry %q", path, tPath)
}
tm[tPath] = &displayEntry{
Primary: entry.Primary,
Secondary: entry.Secondary,
GerritURL: gerritURL(path, tPath),
}
}
return tm, nil
}
func gerritURL(path, tPath string) string {
var project string
var dir string
if strings.HasPrefix(path, "go/") {
project = "go"
dir = tPath
} else if strings.HasPrefix(tPath, "x/") {
parts := strings.SplitN(tPath, "/", 3)
project = parts[1]
if len(parts) == 3 {
dir = parts[2]
}
} else {
return ""
}
url := "https://go-review.googlesource.com/q/project:" + project
if dir != "" {
url += "+dir:" + dir
}
return url
}
// ownerData is passed to the Template, which produces two tables.
type ownerData struct {
Paths map[string]*displayEntry
ArchOSes map[string]*displayEntry
}
func serveIndex(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
indexCache.once.Do(func() {
paths, err := formatEntries(entries)
if err != nil {
indexCache.err = err
return
}
archOses, err := formatEntries(archOses)
if err != nil {
indexCache.err = err
return
}
displayEntries := ownerData{paths, archOses}
var buf bytes.Buffer
indexCache.err = indexTmpl.Execute(&buf, displayEntries)
indexCache.html = buf.Bytes()
})
if indexCache.err != nil {
log.Printf("unable to serve index page HTML: %v", indexCache.err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
w.Write(indexCache.html)
}
// indexCache is a cache of the owners index page HTML.
//
// As long as the owners are defined at package initialization time
// and not modified at runtime, the HTML doesn't change per request.
var indexCache struct {
once sync.Once
html []byte // Page HTML rendered by indexTmpl.
err error
}
var indexTmpl = template.Must(template.New("index").Funcs(template.FuncMap{
"githubURL": func(githubUsername string) string {
if i := strings.Index(githubUsername, "/"); i != -1 {
// A GitHub team like "{org}/{team}".
org, team := githubUsername[:i], githubUsername[i+len("/"):]
return "https://github.com/orgs/" + org + "/teams/" + team
}
return "https://github.com/" + githubUsername
},
}).Parse(`<!DOCTYPE html>
<html lang="en">
<title>Go Code Owners</title>
<meta name=viewport content="width=device-width, initial-scale=1">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: sans-serif;
margin: 1rem 1.5rem;
}
.header {
color: #666;
font-size: 90%;
margin-bottom: 1rem;
}
.table-header {
font-weight: bold;
position: sticky;
top: 0;
}
.table-header,
.entry {
background-color: #fff;
border-bottom: 1px solid #ddd;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
margin: .15rem 0;
padding: .15rem 0;
}
.path,
.primary,
.secondary {
flex-basis: 33.3%;
}
</style>
<header class="header">
<p>Reviews are automatically assigned to primary owners.</p>
<p>Alter these entries at
<a href="https://go.googlesource.com/build/+/master/devapp/owners"
target="_blank" rel="noopener">golang.org/x/build/devapp/owners</a></p>
</header>
<main>
<div class="table-header">
<span class="path">Path</span>
<span class="primary">Primaries</span>
<span class="secondary">Secondaries</span>
</div>
{{range $path, $entry := .Paths}}
<div class="entry">
<span class="path">
{{if $entry.GerritURL}}<a href="{{$entry.GerritURL}}" target="_blank" rel="noopener">{{end}}
{{$path}}
{{if $entry.GerritURL}}</a>{{end}}
</span>
<span class="primary">
{{range .Primary}}
<a href="{{githubURL .GitHubUsername}}" target="_blank" rel="noopener">@{{.GitHubUsername}}</a>
{{end}}
</span>
<span class="secondary">
{{range .Secondary}}
<a href="{{githubURL .GitHubUsername}}" target="_blank" rel="noopener">@{{.GitHubUsername}}</a>
{{end}}
</span>
</div>
{{end}}
<div class="table-header">
<span class="path">Arch/OS</span>
<span class="primary">Primaries</span>
<span class="secondary">Secondaries</span>
</div>
{{range $path, $entry := .ArchOSes}}
<div class="entry">
<span class="path">{{$path}}</span>
<span class="primary">
{{range .Primary}}
<a href="{{githubURL .GitHubUsername}}" target="_blank" rel="noopener">@{{.GitHubUsername}}</a>
{{end}}
</span>
<span class="secondary">
{{range .Secondary}}
<a href="{{githubURL .GitHubUsername}}" target="_blank" rel="noopener">@{{.GitHubUsername}}</a>
{{end}}
</span>
</div>
{{end}}
</main>
`))