exp/cmd/issueserver: an Upspin server that serves GitHub issues
This is a simple read-only Upspin server that serves GitHub issues.
The resulting tree looks like this (issue 1 is closed and 2 is open):
user@example.com/owner/repo/all/1
user@example.com/owner/repo/all/2
user@example.com/owner/repo/closed/1 (link to all/1)
user@example.com/owner/repo/open/2 (link to all/2)
Change-Id: Iac2d03b620f5bc63e24fd000792ced776d8618af
Reviewed-on: https://upspin-review.googlesource.com/10220
Reviewed-by: Rob Pike <r@golang.org>
diff --git a/cmd/issueserver/issueserver.upbox b/cmd/issueserver/issueserver.upbox
new file mode 100644
index 0000000..90c738e
--- /dev/null
+++ b/cmd/issueserver/issueserver.upbox
@@ -0,0 +1,13 @@
+users:
+ - name: issueserver
+ dirserver: $issueserver
+ storeserver: $issueserver
+
+servers:
+ - name: keyserver
+ - name: issueserver
+ importpath: upspin.io/exp/cmd/issueserver
+ flags:
+ watch-github: upspin/upspin
+
+domain: example.com
diff --git a/cmd/issueserver/main.go b/cmd/issueserver/main.go
new file mode 100644
index 0000000..aaf3962
--- /dev/null
+++ b/cmd/issueserver/main.go
@@ -0,0 +1,586 @@
+// Copyright 2017 The Upspin Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Command issueserver is an Upspin server that serves GitHub issues.
+//
+// To try it out, first create a GitHub Personal Access Token which which to
+// access the GitHub API, giving it "repo" privileges.
+// See: https://github.com/settings/tokens/new
+// Put the token string (a string of hex digits) in the file
+// $HOME/upspin/issueserver-access-token and run issueserver with upbox:
+// $ upbox -schema=issueserver.upbox
+// If all goes well, upbox will leave you in an 'upspin shell' session as
+// issueserver@example.com. Type 'ls' to look around.
+package main
+
+import (
+ "bytes"
+ "context"
+ "flag"
+ "fmt"
+ "io/ioutil"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ linebreak "github.com/dgryski/go-linebreak"
+ "golang.org/x/build/maintner"
+
+ "upspin.io/access"
+ "upspin.io/cloud/https"
+ "upspin.io/config"
+ "upspin.io/errors"
+ "upspin.io/flags"
+ "upspin.io/pack"
+ "upspin.io/path"
+ "upspin.io/rpc/dirserver"
+ "upspin.io/rpc/storeserver"
+ "upspin.io/serverutil"
+ "upspin.io/upspin"
+
+ _ "upspin.io/key/transports"
+ _ "upspin.io/pack/eeintegrity"
+)
+
+var (
+ watchGithub = flag.String("watch-github", "", "Comma-separated list of GitHub owner/repo pairs to sync")
+ dataDir = flag.String("data-dir", defaultDataDir, "Local directory in which to write issueserver files")
+ defaultDataDir = filepath.Join(os.Getenv("HOME"), "upspin", "issueserver")
+)
+
+func main() {
+ flags.Parse(flags.Server)
+
+ addr := upspin.NetAddr(flags.NetAddr)
+ ep := upspin.Endpoint{
+ Transport: upspin.Remote,
+ NetAddr: addr,
+ }
+ cfg, err := config.FromFile(flags.Config)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Set up maintner Corpus.
+ corpus := new(maintner.Corpus)
+ logger := maintner.NewDiskMutationLogger(*dataDir)
+ corpus.EnableLeaderMode(logger, *dataDir)
+ if *watchGithub != "" {
+ for _, pair := range strings.Split(*watchGithub, ",") {
+ splits := strings.SplitN(pair, "/", 2)
+ if len(splits) != 2 || splits[1] == "" {
+ log.Fatalf("Invalid github repo: %s. Should be 'owner/repo,owner2/repo2'", pair)
+ }
+ token, err := getGithubToken()
+ if err != nil {
+ log.Fatalf("getting github token: %v", err)
+ }
+ corpus.TrackGithub(splits[0], splits[1], token)
+ }
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ if err := corpus.Initialize(ctx, logger); err != nil {
+ log.Fatal(err)
+ }
+ if *watchGithub != "" {
+ go func() { log.Fatal(fmt.Errorf("Corpus.SyncLoop = %v", corpus.SyncLoop(ctx))) }()
+ }
+
+ // Set up DirServer and StoreServer.
+ s, err := newServer(ep, cfg, corpus)
+ if err != nil {
+ log.Fatal(err)
+ }
+ http.Handle("/api/Store/", storeserver.New(cfg, storeServer{s}, addr))
+ http.Handle("/api/Dir/", dirserver.New(cfg, dirServer{s}, addr))
+
+ https.ListenAndServeFromFlags(nil)
+}
+
+// getGithubToken reads a GitHub Personal Access Token from the file
+// $HOME/upspin/issueserver-github-token of the format "token".
+func getGithubToken() (string, error) {
+ file := filepath.Join(config.Home(), "upspin", "issueserver-github-token")
+ slurp, err := ioutil.ReadFile(file)
+ if err != nil {
+ return "", err
+ }
+ return string(bytes.TrimSpace(token)), nil
+}
+
+// server provides implementations of upspin.DirServer and upspin.StoreServer
+// (accessed by calling the respective methods) that serve a tree containing
+// the GitHub issues in its maintner Corpus.
+//
+// The resulting tree looks like this (issue 1 is closed and 2 is open):
+// user@example.com/owner/repo/all/1
+// user@example.com/owner/repo/all/2
+// user@example.com/owner/repo/closed/1 (link to all/1)
+// user@example.com/owner/repo/open/2 (link to all/2)
+type server struct {
+ ep upspin.Endpoint
+ cfg upspin.Config
+
+ // The Access file entry and data, computed by newServer.
+ accessEntry *upspin.DirEntry
+ accessBytes []byte
+
+ corpus *maintner.Corpus
+
+ mu sync.Mutex
+ issue map[issueKey]packedIssue
+}
+
+type issueKey struct {
+ name upspin.PathName
+ updated time.Time
+}
+
+type packedIssue struct {
+ de *upspin.DirEntry
+ data []byte
+}
+
+func (k issueKey) Ref() upspin.Reference {
+ return upspin.Reference(fmt.Sprintf("%v %v", k.name, k.updated.Format(time.RFC3339)))
+}
+
+func refToIssueKey(ref upspin.Reference) (issueKey, error) {
+ p := strings.SplitN(string(ref), " ", 2)
+ if len(p) != 2 {
+ return issueKey{}, errors.Str("invalid reference")
+ }
+ updated, err := time.Parse(time.RFC3339, p[1])
+ if err != nil {
+ return issueKey{}, err
+ }
+ return issueKey{
+ name: upspin.PathName(p[0]),
+ updated: updated,
+ }, nil
+}
+
+type dirServer struct {
+ *server
+}
+
+type storeServer struct {
+ *server
+}
+
+const (
+ accessRef = upspin.Reference(access.AccessFile)
+ accessFile = "read,list:all\n"
+)
+
+var accessRefdata = upspin.Refdata{Reference: accessRef}
+
+func newServer(ep upspin.Endpoint, cfg upspin.Config, c *maintner.Corpus) (*server, error) {
+ s := &server{
+ ep: ep,
+ cfg: cfg,
+ corpus: c,
+ issue: make(map[issueKey]packedIssue),
+ }
+
+ var err error
+ accessName := upspin.PathName(s.cfg.UserName()) + "/" + access.AccessFile
+ s.accessEntry, s.accessBytes, err = s.pack(accessName, accessRef, []byte(accessFile))
+ if err != nil {
+ return nil, err
+ }
+
+ return s, nil
+}
+
+const packing = upspin.EEIntegrityPack
+
+// pack packs the given data and returns the resulting DirEntry and ciphertext.
+func (s *server) pack(name upspin.PathName, ref upspin.Reference, data []byte) (*upspin.DirEntry, []byte, error) {
+ de := &upspin.DirEntry{
+ Writer: s.cfg.UserName(),
+ Name: name,
+ SignedName: name,
+ Packing: packing,
+ Time: upspin.Now(),
+ Sequence: 1,
+ }
+
+ bp, err := pack.Lookup(packing).Pack(s.cfg, de)
+ if err != nil {
+ return nil, nil, err
+ }
+ cipher, err := bp.Pack(data)
+ if err != nil {
+ return nil, nil, err
+ }
+ bp.SetLocation(upspin.Location{
+ Endpoint: s.ep,
+ Reference: ref,
+ })
+ return de, cipher, bp.Close()
+}
+
+// packIssue formats and packs the given issue at the given path, updates the
+// server's issue map, and returns the resulting DirEntry. If the issue is
+// already present in the issue map then that DirEntry is returned instead.
+func (s *server) packIssue(name upspin.PathName, issue *maintner.GitHubIssue) (*upspin.DirEntry, error) {
+ key := issueKey{
+ name: name,
+ updated: issue.Updated,
+ }
+ s.mu.Lock()
+ packed, ok := s.issue[key]
+ s.mu.Unlock()
+ if ok {
+ return packed.de, nil
+ }
+ de, data, err := s.pack(name, key.Ref(), formatIssue(issue))
+ if err != nil {
+ return nil, err
+ }
+ s.mu.Lock()
+ s.issue[key] = packedIssue{
+ de: de,
+ data: data,
+ }
+ s.mu.Unlock()
+ return de, nil
+}
+
+// formatIssue formats the given issue as text.
+func formatIssue(issue *maintner.GitHubIssue) []byte {
+ const timeFormat = "15:04 on 2 Jan 2006"
+ var buf bytes.Buffer
+ fmt.Fprintf(&buf, "%s\ncreated %s at %s\n\n%s\n",
+ issue.Title,
+ formatUser(issue.User),
+ issue.Created.Format(timeFormat),
+ wrap("\t", issue.Body))
+
+ type update struct {
+ time time.Time
+ printed []byte
+ }
+ var updates []update
+ issue.ForeachComment(func(comment *maintner.GitHubComment) error {
+ var buf bytes.Buffer
+ fmt.Fprintf(&buf, "comment %s at %s\n\n%s\n",
+ formatUser(comment.User),
+ comment.Created.Format(timeFormat),
+ wrap("\t", comment.Body))
+ updates = append(updates, update{comment.Created, buf.Bytes()})
+ return nil
+ })
+ issue.ForeachEvent(func(event *maintner.GitHubIssueEvent) error {
+ var buf bytes.Buffer
+ switch event.Type {
+ case "closed", "reopened":
+ fmt.Fprintf(&buf, "%s %s at %s\n\n",
+ event.Type,
+ formatUser(event.Actor),
+ event.Created.Format(timeFormat))
+ default:
+ // TODO(adg): other types
+ }
+ updates = append(updates, update{event.Created, buf.Bytes()})
+ return nil
+ })
+ sort.Slice(updates, func(i, j int) bool {
+ return updates[i].time.Before(updates[j].time)
+ })
+ for _, u := range updates {
+ buf.Write(u.printed)
+ }
+ return buf.Bytes()
+}
+
+// formatUser returns "by username" or the empty string if user is nil.
+func formatUser(user *maintner.GitHubUser) string {
+ if user != nil {
+ return "by " + user.Login
+ }
+ return ""
+}
+
+// wrap wraps the given text and adds prefix to the beginning of each line.
+func wrap(prefix, text string) []byte {
+ maxWidth := 80
+ for _, c := range prefix {
+ maxWidth -= 1
+ if c == '\t' {
+ maxWidth -= 7
+ }
+ }
+ var buf bytes.Buffer
+ for _, line := range strings.Split(linebreak.Wrap(text, maxWidth, maxWidth), "\n") {
+ buf.WriteString(prefix)
+ buf.WriteString(line)
+ buf.WriteByte('\n')
+ }
+ return buf.Bytes()
+}
+
+// These methods implement upspin.Service.
+
+func (s *server) Endpoint() upspin.Endpoint { return s.ep }
+func (*server) Ping() bool { return true }
+func (*server) Close() {}
+
+// These methods implement upspin.Dialer.
+
+func (s storeServer) Dial(upspin.Config, upspin.Endpoint) (upspin.Service, error) { return s, nil }
+func (s dirServer) Dial(upspin.Config, upspin.Endpoint) (upspin.Service, error) { return s, nil }
+
+// These methods implement upspin.DirServer.
+
+func (s dirServer) Lookup(name upspin.PathName) (*upspin.DirEntry, error) {
+ p, err := path.Parse(name)
+ if err != nil {
+ return nil, err
+ }
+
+ switch p.FilePath() {
+ case "": // Root directory.
+ return directory(p.Path()), nil
+ case access.AccessFile:
+ return s.accessEntry, nil
+ }
+
+ git := s.corpus.GitHub()
+ switch p.NElem() {
+ case 1: // Owner directory.
+ ok := false
+ git.ForeachRepo(func(repo *maintner.GitHubRepo) error {
+ if repo.ID().Owner == p.Elem(0) {
+ ok = true
+ }
+ return nil
+ })
+ if ok {
+ return directory(p.Path()), nil
+ }
+ case 2: // User directory.
+ if git.Repo(p.Elem(0), p.Elem(1)) != nil {
+ return directory(p.Path()), nil
+ }
+ case 3: // State directory.
+ if validState(p.Elem(2)) {
+ return directory(p.Path()), nil
+ }
+ case 4: // Issue file or link.
+ state := p.Elem(2)
+ if !validState(state) {
+ break
+ }
+ repo := git.Repo(p.Elem(0), p.Elem(1))
+ n, err := strconv.ParseInt(p.Elem(3), 10, 32)
+ if err != nil {
+ break
+ }
+ issue := repo.Issue(int32(n))
+ if issue == nil {
+ break
+ }
+ if state == "open" && issue.Closed || state == "closed" && !issue.Closed {
+ break
+ }
+ if state == "open" || state == "closed" {
+ return link(p.Path(), issue), upspin.ErrFollowLink
+ }
+ de, err := s.packIssue(p.Path(), issue)
+ if err != nil {
+ return nil, errors.E(name, err)
+ }
+ return de, nil
+ }
+
+ return nil, errors.E(name, errors.NotExist)
+}
+
+// validState reports whether the given issue state
+// path component is one of (open, closed, all).
+func validState(state string) bool {
+ return state == "open" || state == "closed" || state == "all"
+}
+
+// directory returns a DirEntry for the directory with the given name.
+func directory(name upspin.PathName) *upspin.DirEntry {
+ return &upspin.DirEntry{
+ Name: name,
+ SignedName: name,
+ Attr: upspin.AttrDirectory,
+ Time: upspin.Now(),
+ }
+}
+
+// link returns a DirEntry for the link with the given name
+// that points to the given issue.
+func link(name upspin.PathName, issue *maintner.GitHubIssue) *upspin.DirEntry {
+ p, _ := path.Parse(name)
+ link := p.Drop(2).Path() + upspin.PathName(fmt.Sprintf("/all/%d", issue.Number))
+ return &upspin.DirEntry{
+ Packing: upspin.PlainPack,
+ Name: name,
+ SignedName: name,
+ Link: link,
+ Attr: upspin.AttrLink,
+ Time: upspin.Now(),
+ }
+}
+
+func (s dirServer) Glob(pattern string) ([]*upspin.DirEntry, error) {
+ return serverutil.Glob(pattern, s.Lookup, s.listDir)
+}
+
+func (s dirServer) listDir(name upspin.PathName) ([]*upspin.DirEntry, error) {
+ p, err := path.Parse(name)
+ if err != nil {
+ return nil, err
+ }
+ if p.User() != s.cfg.UserName() {
+ return nil, errors.E(name, errors.NotExist)
+ }
+
+ var des []*upspin.DirEntry
+
+ switch p.NElem() {
+ case 0:
+ des = append(des, s.accessEntry)
+ owners := repoIDStrings(s.corpus, func(id maintner.GithubRepoID) string {
+ return id.Owner
+ })
+ for _, owner := range owners {
+ name := p.Path() + upspin.PathName(owner)
+ des = append(des, directory(name))
+ }
+ case 1:
+ repos := repoIDStrings(s.corpus, func(id maintner.GithubRepoID) string {
+ if id.Owner == p.Elem(0) {
+ return id.Repo
+ }
+ return ""
+ })
+ for _, repo := range repos {
+ name := p.Path() + upspin.PathName("/"+repo)
+ des = append(des, directory(name))
+ }
+ case 2:
+ if s.corpus.GitHub().Repo(p.Elem(0), p.Elem(1)) == nil {
+ break
+ }
+ des = append(des,
+ directory(p.Path()+"/all"),
+ directory(p.Path()+"/closed"),
+ directory(p.Path()+"/open"),
+ )
+ case 3:
+ state := p.Elem(2)
+ if !validState(state) {
+ break
+ }
+ repo := s.corpus.GitHub().Repo(p.Elem(0), p.Elem(1))
+ if repo == nil {
+ break
+ }
+ err := repo.ForeachIssue(func(issue *maintner.GitHubIssue) error {
+ if state == "open" && issue.Closed || state == "closed" && !issue.Closed {
+ return nil
+ }
+ name := p.Path() + upspin.PathName(fmt.Sprintf("/%d", issue.Number))
+ if state == "open" || state == "closed" {
+ des = append(des, link(name, issue))
+ return nil
+ }
+ de, err := s.packIssue(name, issue)
+ if err != nil {
+ return errors.E(name, err)
+ }
+ des = append(des, de)
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if len(des) == 0 {
+ return nil, errors.E(name, errors.NotExist)
+ }
+ return des, nil
+}
+
+// repoIDStrings returns a deduplicated, lexicographically sorted list of
+// strings returned by iterating over the given corpus' GitHub repositories and
+// calling fn for each of them. Empty strings returned by fn are ignored.
+func repoIDStrings(corpus *maintner.Corpus, fn func(maintner.GithubRepoID) string) []string {
+ idmap := map[string]bool{}
+ corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error {
+ idmap[fn(repo.ID())] = true
+ return nil
+ })
+ var ids []string
+ for id := range idmap {
+ if id == "" {
+ continue
+ }
+ ids = append(ids, id)
+ }
+ sort.Strings(ids)
+ return ids
+}
+
+func (s dirServer) WhichAccess(name upspin.PathName) (*upspin.DirEntry, error) {
+ return s.accessEntry, nil
+}
+
+// This method implements upspin.StoreServer.
+
+func (s storeServer) Get(ref upspin.Reference) ([]byte, *upspin.Refdata, []upspin.Location, error) {
+ if ref == accessRef {
+ return s.accessBytes, &accessRefdata, nil, nil
+ }
+ key, err := refToIssueKey(ref)
+ if err != nil {
+ return nil, nil, nil, errors.E(errors.NotExist, err)
+ }
+ s.mu.Lock()
+ issue, ok := s.issue[key]
+ s.mu.Unlock()
+ if !ok {
+ return nil, nil, nil, errors.E(errors.NotExist)
+ }
+ return issue.data, &upspin.Refdata{Reference: ref}, nil, nil
+}
+
+// The DirServer and StoreServer methods below are not implemented.
+
+var errNotImplemented = errors.E(errors.Permission, errors.Str("method not implemented: demoserver is read-only"))
+
+func (dirServer) Watch(name upspin.PathName, order int64, done <-chan struct{}) (<-chan upspin.Event, error) {
+ return nil, upspin.ErrNotSupported
+}
+
+func (dirServer) Put(entry *upspin.DirEntry) (*upspin.DirEntry, error) {
+ return nil, errNotImplemented
+}
+
+func (dirServer) Delete(name upspin.PathName) (*upspin.DirEntry, error) {
+ return nil, errNotImplemented
+}
+
+func (storeServer) Put(data []byte) (*upspin.Refdata, error) {
+ return nil, errNotImplemented
+}
+
+func (storeServer) Delete(ref upspin.Reference) error {
+ return errNotImplemented
+}