exp.upspin.io@v0.0.0-20230625230448-5076e5b595ec/cmd/issueserver/main.go (about)

     1  // Copyright 2017 The Upspin 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  // Command issueserver is an Upspin server that serves GitHub issues.
     6  //
     7  // To try it out, first create a GitHub Personal Access Token which which to
     8  // access the GitHub API, giving it "repo" privileges.
     9  // See: https://github.com/settings/tokens/new
    10  // Put the token string (a string of hex digits) in the file
    11  // $HOME/upspin/issueserver-github-token and run issueserver with upbox:
    12  //   $ upbox -schema=issueserver.upbox
    13  // If all goes well, upbox will leave you in an 'upspin shell' session as
    14  // issueserver@example.com. Type 'ls' to look around.
    15  package main // import "exp.upspin.io/cmd/issueserver"
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"flag"
    21  	"fmt"
    22  	"io/ioutil"
    23  	"log"
    24  	"net/http"
    25  	"os"
    26  	"path/filepath"
    27  	"sort"
    28  	"strconv"
    29  	"strings"
    30  	"sync"
    31  	"time"
    32  
    33  	linebreak "github.com/dgryski/go-linebreak"
    34  	"golang.org/x/build/maintner"
    35  
    36  	"upspin.io/access"
    37  	"upspin.io/cloud/https"
    38  	"upspin.io/config"
    39  	"upspin.io/errors"
    40  	"upspin.io/flags"
    41  	"upspin.io/pack"
    42  	"upspin.io/path"
    43  	"upspin.io/rpc/dirserver"
    44  	"upspin.io/rpc/storeserver"
    45  	"upspin.io/serverutil"
    46  	"upspin.io/upspin"
    47  
    48  	_ "upspin.io/key/transports"
    49  	_ "upspin.io/pack/eeintegrity"
    50  )
    51  
    52  var (
    53  	watchGitHub    = flag.String("watch-github", "", "Comma-separated list of GitHub owner/repo pairs to sync")
    54  	dataDir        = flag.String("data-dir", defaultDataDir, "Local directory in which to write issueserver files")
    55  	defaultDataDir = filepath.Join(os.Getenv("HOME"), "upspin", "issueserver")
    56  )
    57  
    58  func main() {
    59  	flags.Parse(flags.Server)
    60  
    61  	addr := upspin.NetAddr(flags.NetAddr)
    62  	ep := upspin.Endpoint{
    63  		Transport: upspin.Remote,
    64  		NetAddr:   addr,
    65  	}
    66  	cfg, err := config.FromFile(flags.Config)
    67  	if err != nil {
    68  		log.Fatal(err)
    69  	}
    70  
    71  	// Set up maintner Corpus.
    72  	corpus := new(maintner.Corpus)
    73  	logger := maintner.NewDiskMutationLogger(*dataDir)
    74  	corpus.EnableLeaderMode(logger, *dataDir)
    75  	if *watchGitHub != "" {
    76  		for _, pair := range strings.Split(*watchGitHub, ",") {
    77  			splits := strings.SplitN(pair, "/", 2)
    78  			if len(splits) != 2 || splits[1] == "" {
    79  				log.Fatalf("Invalid github repo: %s. Should be 'owner/repo,owner2/repo2'", pair)
    80  			}
    81  			token, err := getGitHubToken()
    82  			if err != nil {
    83  				log.Fatalf("getting github token: %v", err)
    84  			}
    85  			corpus.TrackGitHub(splits[0], splits[1], token)
    86  		}
    87  	}
    88  	ctx, cancel := context.WithCancel(context.Background())
    89  	defer cancel()
    90  	if err := corpus.Initialize(ctx, logger); err != nil {
    91  		log.Fatal(err)
    92  	}
    93  	if *watchGitHub != "" {
    94  		go func() { log.Fatal(fmt.Errorf("Corpus.SyncLoop = %v", corpus.SyncLoop(ctx))) }()
    95  	}
    96  
    97  	// Set up DirServer and StoreServer.
    98  	s, err := newServer(ep, cfg, corpus)
    99  	if err != nil {
   100  		log.Fatal(err)
   101  	}
   102  	http.Handle("/api/Store/", storeserver.New(cfg, storeServer{s}, addr))
   103  	http.Handle("/api/Dir/", dirserver.New(cfg, dirServer{s}, addr))
   104  
   105  	https.ListenAndServeFromFlags(nil)
   106  }
   107  
   108  // getGitHubToken reads a GitHub Personal Access Token from the file
   109  // $HOME/upspin/issueserver-github-token of the format "token".
   110  func getGitHubToken() (string, error) {
   111  	file := filepath.Join(config.Home(), "upspin", "issueserver-github-token")
   112  	token, err := ioutil.ReadFile(file)
   113  	if err != nil {
   114  		return "", err
   115  	}
   116  	return string(bytes.TrimSpace(token)), nil
   117  }
   118  
   119  // server provides implementations of upspin.DirServer and upspin.StoreServer
   120  // (accessed by calling the respective methods) that serve a tree containing
   121  // the GitHub issues in its maintner Corpus.
   122  //
   123  // The resulting tree looks like this (issue 1 is closed and 2 is open):
   124  // 	user@example.com/owner/repo/all/1
   125  // 	user@example.com/owner/repo/all/2
   126  // 	user@example.com/owner/repo/closed/1  (link to all/1)
   127  // 	user@example.com/owner/repo/open/2    (link to all/2)
   128  type server struct {
   129  	ep  upspin.Endpoint
   130  	cfg upspin.Config
   131  
   132  	// The Access file entry and data, computed by newServer.
   133  	accessEntry *upspin.DirEntry
   134  	accessBytes []byte
   135  
   136  	corpus *maintner.Corpus
   137  
   138  	mu    sync.Mutex
   139  	issue map[issueKey]packedIssue
   140  }
   141  
   142  type issueKey struct {
   143  	name    upspin.PathName
   144  	updated time.Time
   145  }
   146  
   147  type packedIssue struct {
   148  	de   *upspin.DirEntry
   149  	data []byte
   150  }
   151  
   152  func (k issueKey) Ref() upspin.Reference {
   153  	return upspin.Reference(fmt.Sprintf("%v %v", k.name, k.updated.Format(time.RFC3339)))
   154  }
   155  
   156  func refToIssueKey(ref upspin.Reference) (issueKey, error) {
   157  	p := strings.SplitN(string(ref), " ", 2)
   158  	if len(p) != 2 {
   159  		return issueKey{}, errors.Str("invalid reference")
   160  	}
   161  	updated, err := time.Parse(time.RFC3339, p[1])
   162  	if err != nil {
   163  		return issueKey{}, err
   164  	}
   165  	return issueKey{
   166  		name:    upspin.PathName(p[0]),
   167  		updated: updated,
   168  	}, nil
   169  }
   170  
   171  type dirServer struct {
   172  	*server
   173  }
   174  
   175  type storeServer struct {
   176  	*server
   177  }
   178  
   179  const (
   180  	accessRef  = upspin.Reference(access.AccessFile)
   181  	accessFile = "read,list:all\n"
   182  )
   183  
   184  var accessRefdata = upspin.Refdata{Reference: accessRef}
   185  
   186  func newServer(ep upspin.Endpoint, cfg upspin.Config, c *maintner.Corpus) (*server, error) {
   187  	s := &server{
   188  		ep:     ep,
   189  		cfg:    cfg,
   190  		corpus: c,
   191  		issue:  make(map[issueKey]packedIssue),
   192  	}
   193  
   194  	var err error
   195  	accessName := upspin.PathName(s.cfg.UserName()) + "/" + access.AccessFile
   196  	s.accessEntry, s.accessBytes, err = s.pack(accessName, accessRef, []byte(accessFile))
   197  	if err != nil {
   198  		return nil, err
   199  	}
   200  
   201  	return s, nil
   202  }
   203  
   204  const packing = upspin.EEIntegrityPack
   205  
   206  // pack packs the given data and returns the resulting DirEntry and ciphertext.
   207  func (s *server) pack(name upspin.PathName, ref upspin.Reference, data []byte) (*upspin.DirEntry, []byte, error) {
   208  	de := &upspin.DirEntry{
   209  		Writer:     s.cfg.UserName(),
   210  		Name:       name,
   211  		SignedName: name,
   212  		Packing:    packing,
   213  		Time:       upspin.Now(),
   214  		Sequence:   1,
   215  	}
   216  
   217  	bp, err := pack.Lookup(packing).Pack(s.cfg, de)
   218  	if err != nil {
   219  		return nil, nil, err
   220  	}
   221  	cipher, err := bp.Pack(data)
   222  	if err != nil {
   223  		return nil, nil, err
   224  	}
   225  	bp.SetLocation(upspin.Location{
   226  		Endpoint:  s.ep,
   227  		Reference: ref,
   228  	})
   229  	return de, cipher, bp.Close()
   230  }
   231  
   232  // packIssue formats and packs the given issue at the given path, updates the
   233  // server's issue map, and returns the resulting DirEntry. If the issue is
   234  // already present in the issue map then that DirEntry is returned instead.
   235  func (s *server) packIssue(name upspin.PathName, issue *maintner.GitHubIssue) (*upspin.DirEntry, error) {
   236  	key := issueKey{
   237  		name:    name,
   238  		updated: issue.Updated,
   239  	}
   240  	s.mu.Lock()
   241  	packed, ok := s.issue[key]
   242  	s.mu.Unlock()
   243  	if ok {
   244  		return packed.de, nil
   245  	}
   246  	de, data, err := s.pack(name, key.Ref(), formatIssue(issue))
   247  	if err != nil {
   248  		return nil, err
   249  	}
   250  	s.mu.Lock()
   251  	s.issue[key] = packedIssue{
   252  		de:   de,
   253  		data: data,
   254  	}
   255  	s.mu.Unlock()
   256  	return de, nil
   257  }
   258  
   259  // formatIssue formats the given issue as text.
   260  func formatIssue(issue *maintner.GitHubIssue) []byte {
   261  	const timeFormat = "15:04 on 2 Jan 2006"
   262  	var buf bytes.Buffer
   263  	fmt.Fprintf(&buf, "%s\ncreated %s at %s\n\n%s\n",
   264  		issue.Title,
   265  		formatUser(issue.User),
   266  		issue.Created.Format(timeFormat),
   267  		wrap("\t", issue.Body))
   268  
   269  	type update struct {
   270  		time    time.Time
   271  		printed []byte
   272  	}
   273  	var updates []update
   274  	issue.ForeachComment(func(comment *maintner.GitHubComment) error {
   275  		var buf bytes.Buffer
   276  		fmt.Fprintf(&buf, "comment %s at %s\n\n%s\n",
   277  			formatUser(comment.User),
   278  			comment.Created.Format(timeFormat),
   279  			wrap("\t", comment.Body))
   280  		updates = append(updates, update{comment.Created, buf.Bytes()})
   281  		return nil
   282  	})
   283  	issue.ForeachEvent(func(event *maintner.GitHubIssueEvent) error {
   284  		var buf bytes.Buffer
   285  		switch event.Type {
   286  		case "closed", "reopened":
   287  			fmt.Fprintf(&buf, "%s %s at %s\n\n",
   288  				event.Type,
   289  				formatUser(event.Actor),
   290  				event.Created.Format(timeFormat))
   291  		default:
   292  			// TODO(adg): other types
   293  		}
   294  		updates = append(updates, update{event.Created, buf.Bytes()})
   295  		return nil
   296  	})
   297  	sort.Slice(updates, func(i, j int) bool {
   298  		return updates[i].time.Before(updates[j].time)
   299  	})
   300  	for _, u := range updates {
   301  		buf.Write(u.printed)
   302  	}
   303  	return buf.Bytes()
   304  }
   305  
   306  // formatUser returns "by username" or the empty string if user is nil.
   307  func formatUser(user *maintner.GitHubUser) string {
   308  	if user != nil {
   309  		return "by " + user.Login
   310  	}
   311  	return ""
   312  }
   313  
   314  // wrap wraps the given text and adds prefix to the beginning of each line.
   315  func wrap(prefix, text string) []byte {
   316  	maxWidth := 80
   317  	for _, c := range prefix {
   318  		maxWidth -= 1
   319  		if c == '\t' {
   320  			maxWidth -= 7
   321  		}
   322  	}
   323  	var buf bytes.Buffer
   324  	for _, line := range strings.Split(linebreak.Wrap(text, maxWidth, maxWidth), "\n") {
   325  		buf.WriteString(prefix)
   326  		buf.WriteString(line)
   327  		buf.WriteByte('\n')
   328  	}
   329  	return buf.Bytes()
   330  }
   331  
   332  // These methods implement upspin.Service.
   333  
   334  func (s *server) Endpoint() upspin.Endpoint { return s.ep }
   335  func (*server) Ping() bool                  { return true }
   336  func (*server) Close()                      {}
   337  
   338  // These methods implement upspin.Dialer.
   339  
   340  func (s storeServer) Dial(upspin.Config, upspin.Endpoint) (upspin.Service, error) { return s, nil }
   341  func (s dirServer) Dial(upspin.Config, upspin.Endpoint) (upspin.Service, error)   { return s, nil }
   342  
   343  // These methods implement upspin.DirServer.
   344  
   345  func (s dirServer) Lookup(name upspin.PathName) (*upspin.DirEntry, error) {
   346  	p, err := path.Parse(name)
   347  	if err != nil {
   348  		return nil, err
   349  	}
   350  
   351  	switch p.FilePath() {
   352  	case "": // Root directory.
   353  		return directory(p.Path()), nil
   354  	case access.AccessFile:
   355  		return s.accessEntry, nil
   356  	}
   357  
   358  	git := s.corpus.GitHub()
   359  	switch p.NElem() {
   360  	case 1: // Owner directory.
   361  		ok := false
   362  		git.ForeachRepo(func(repo *maintner.GitHubRepo) error {
   363  			if repo.ID().Owner == p.Elem(0) {
   364  				ok = true
   365  			}
   366  			return nil
   367  		})
   368  		if ok {
   369  			return directory(p.Path()), nil
   370  		}
   371  	case 2: // User directory.
   372  		if git.Repo(p.Elem(0), p.Elem(1)) != nil {
   373  			return directory(p.Path()), nil
   374  		}
   375  	case 3: // State directory.
   376  		if validState(p.Elem(2)) {
   377  			return directory(p.Path()), nil
   378  		}
   379  	case 4: // Issue file or link.
   380  		state := p.Elem(2)
   381  		if !validState(state) {
   382  			break
   383  		}
   384  		repo := git.Repo(p.Elem(0), p.Elem(1))
   385  		n, err := strconv.ParseInt(p.Elem(3), 10, 32)
   386  		if err != nil {
   387  			break
   388  		}
   389  		issue := repo.Issue(int32(n))
   390  		if issue == nil {
   391  			break
   392  		}
   393  		if state == "open" && issue.Closed || state == "closed" && !issue.Closed {
   394  			break
   395  		}
   396  		if state == "open" || state == "closed" {
   397  			return link(p.Path(), issue), upspin.ErrFollowLink
   398  		}
   399  		de, err := s.packIssue(p.Path(), issue)
   400  		if err != nil {
   401  			return nil, errors.E(name, err)
   402  		}
   403  		return de, nil
   404  	}
   405  
   406  	return nil, errors.E(name, errors.NotExist)
   407  }
   408  
   409  // validState reports whether the given issue state
   410  // path component is one of (open, closed, all).
   411  func validState(state string) bool {
   412  	return state == "open" || state == "closed" || state == "all"
   413  }
   414  
   415  // directory returns a DirEntry for the directory with the given name.
   416  func directory(name upspin.PathName) *upspin.DirEntry {
   417  	return &upspin.DirEntry{
   418  		Name:       name,
   419  		SignedName: name,
   420  		Attr:       upspin.AttrDirectory,
   421  		Time:       upspin.Now(),
   422  	}
   423  }
   424  
   425  // link returns a DirEntry for the link with the given name
   426  // that points to the given issue.
   427  func link(name upspin.PathName, issue *maintner.GitHubIssue) *upspin.DirEntry {
   428  	p, _ := path.Parse(name)
   429  	link := p.Drop(2).Path() + upspin.PathName(fmt.Sprintf("/all/%d", issue.Number))
   430  	return &upspin.DirEntry{
   431  		Packing:    upspin.PlainPack,
   432  		Name:       name,
   433  		SignedName: name,
   434  		Link:       link,
   435  		Attr:       upspin.AttrLink,
   436  		Time:       upspin.Now(),
   437  	}
   438  }
   439  
   440  func (s dirServer) Glob(pattern string) ([]*upspin.DirEntry, error) {
   441  	return serverutil.Glob(pattern, s.Lookup, s.listDir)
   442  }
   443  
   444  func (s dirServer) listDir(name upspin.PathName) ([]*upspin.DirEntry, error) {
   445  	p, err := path.Parse(name)
   446  	if err != nil {
   447  		return nil, err
   448  	}
   449  	if p.User() != s.cfg.UserName() {
   450  		return nil, errors.E(name, errors.NotExist)
   451  	}
   452  
   453  	var des []*upspin.DirEntry
   454  
   455  	switch p.NElem() {
   456  	case 0:
   457  		des = append(des, s.accessEntry)
   458  		owners := repoIDStrings(s.corpus, func(id maintner.GitHubRepoID) string {
   459  			return id.Owner
   460  		})
   461  		for _, owner := range owners {
   462  			name := p.Path() + upspin.PathName(owner)
   463  			des = append(des, directory(name))
   464  		}
   465  	case 1:
   466  		repos := repoIDStrings(s.corpus, func(id maintner.GitHubRepoID) string {
   467  			if id.Owner == p.Elem(0) {
   468  				return id.Repo
   469  			}
   470  			return ""
   471  		})
   472  		for _, repo := range repos {
   473  			name := p.Path() + upspin.PathName("/"+repo)
   474  			des = append(des, directory(name))
   475  		}
   476  	case 2:
   477  		if s.corpus.GitHub().Repo(p.Elem(0), p.Elem(1)) == nil {
   478  			break
   479  		}
   480  		des = append(des,
   481  			directory(p.Path()+"/all"),
   482  			directory(p.Path()+"/closed"),
   483  			directory(p.Path()+"/open"),
   484  		)
   485  	case 3:
   486  		state := p.Elem(2)
   487  		if !validState(state) {
   488  			break
   489  		}
   490  		repo := s.corpus.GitHub().Repo(p.Elem(0), p.Elem(1))
   491  		if repo == nil {
   492  			break
   493  		}
   494  		err := repo.ForeachIssue(func(issue *maintner.GitHubIssue) error {
   495  			if state == "open" && issue.Closed || state == "closed" && !issue.Closed {
   496  				return nil
   497  			}
   498  			name := p.Path() + upspin.PathName(fmt.Sprintf("/%d", issue.Number))
   499  			if state == "open" || state == "closed" {
   500  				des = append(des, link(name, issue))
   501  				return nil
   502  			}
   503  			de, err := s.packIssue(name, issue)
   504  			if err != nil {
   505  				return errors.E(name, err)
   506  			}
   507  			des = append(des, de)
   508  			return nil
   509  		})
   510  		if err != nil {
   511  			return nil, err
   512  		}
   513  	}
   514  
   515  	if len(des) == 0 {
   516  		return nil, errors.E(name, errors.NotExist)
   517  	}
   518  	return des, nil
   519  }
   520  
   521  // repoIDStrings returns a deduplicated, lexicographically sorted list of
   522  // strings returned by iterating over the given corpus' GitHub repositories and
   523  // calling fn for each of them. Empty strings returned by fn are ignored.
   524  func repoIDStrings(corpus *maintner.Corpus, fn func(maintner.GitHubRepoID) string) []string {
   525  	idmap := map[string]bool{}
   526  	corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error {
   527  		idmap[fn(repo.ID())] = true
   528  		return nil
   529  	})
   530  	var ids []string
   531  	for id := range idmap {
   532  		if id == "" {
   533  			continue
   534  		}
   535  		ids = append(ids, id)
   536  	}
   537  	sort.Strings(ids)
   538  	return ids
   539  }
   540  
   541  func (s dirServer) WhichAccess(name upspin.PathName) (*upspin.DirEntry, error) {
   542  	return s.accessEntry, nil
   543  }
   544  
   545  // This method implements upspin.StoreServer.
   546  
   547  func (s storeServer) Get(ref upspin.Reference) ([]byte, *upspin.Refdata, []upspin.Location, error) {
   548  	if ref == accessRef {
   549  		return s.accessBytes, &accessRefdata, nil, nil
   550  	}
   551  	key, err := refToIssueKey(ref)
   552  	if err != nil {
   553  		return nil, nil, nil, errors.E(errors.NotExist, err)
   554  	}
   555  	s.mu.Lock()
   556  	issue, ok := s.issue[key]
   557  	s.mu.Unlock()
   558  	if !ok {
   559  		return nil, nil, nil, errors.E(errors.NotExist)
   560  	}
   561  	return issue.data, &upspin.Refdata{Reference: ref}, nil, nil
   562  }
   563  
   564  // The DirServer and StoreServer methods below are not implemented.
   565  
   566  var errNotImplemented = errors.E(errors.Permission, "method not implemented: demoserver is read-only")
   567  
   568  func (dirServer) Watch(name upspin.PathName, seq int64, done <-chan struct{}) (<-chan upspin.Event, error) {
   569  	return nil, upspin.ErrNotSupported
   570  }
   571  
   572  func (dirServer) Put(entry *upspin.DirEntry) (*upspin.DirEntry, error) {
   573  	return nil, errNotImplemented
   574  }
   575  
   576  func (dirServer) Delete(name upspin.PathName) (*upspin.DirEntry, error) {
   577  	return nil, errNotImplemented
   578  }
   579  
   580  func (storeServer) Put(data []byte) (*upspin.Refdata, error) {
   581  	return nil, errNotImplemented
   582  }
   583  
   584  func (storeServer) Delete(ref upspin.Reference) error {
   585  	return errNotImplemented
   586  }