blob: 3cd58336c7a0e6bc9ced5452ed22c3c69d8a41d7 [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 Request struct {
Payload struct {
Paths []string `json:"paths"`
} `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
}
var resp Response
resp.Payload.Entries = make(map[string]*Entry)
for _, p := range req.Payload.Paths {
resp.Payload.Entries[p] = match(p)
}
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())
}
// translatePath 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 translatePath(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
}
// translateOwnersPaths returns a copy of entries with all its keys
// adjusted for better readability on https://dev.golang.org/owners.
func translateOwnersPaths() (map[string]*Entry, error) {
tm := make(map[string]*Entry)
for path, entry := range entries {
tPath := translatePath(path)
if _, ok := tm[tPath]; ok {
return nil, fmt.Errorf("path translation of %q creates a duplicate entry %q", path, tPath)
}
tm[tPath] = entry
}
return tm, nil
}
func serveIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
indexCache.once.Do(func() {
displayEntries, err := translateOwnersPaths()
if err != nil {
indexCache.err = err
return
}
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">
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>
</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 := .}}
<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>
`))