Merge pull request #166 from MichaelMure/github-exporter

Michael Muré created

[Bridge] GitHub exporter

Change summary

Gopkg.lock                                      |   4 
README.md                                       |  36 
bridge/core/bridge.go                           |   8 
bridge/core/export.go                           | 101 ++
bridge/core/interfaces.go                       |   2 
bridge/github/export.go                         | 795 +++++++++++++++++++
bridge/github/export_mutation.go                |  74 +
bridge/github/export_test.go                    | 340 ++++++++
bridge/github/github.go                         |   4 
bridge/github/import.go                         |  13 
bridge/github/import_query.go                   |  11 
bridge/github/import_test.go                    |   2 
bridge/github/iterator.go                       |  27 
bug/operation.go                                |   7 
bug/snapshot.go                                 |  45 +
cache/bug_cache.go                              |  18 
commands/bridge_pull.go                         |   4 
commands/bridge_push.go                         |  62 +
doc/man/git-bug-bridge-push.1                   |  29 
doc/man/git-bug-bridge.1                        |   2 
doc/md/git-bug_bridge.md                        |   1 
doc/md/git-bug_bridge_push.md                   |  22 
misc/bash_completion/git-bug                    |  21 
misc/powershell_completion/git-bug              |   4 
misc/zsh_completion/git-bug                     |   8 
vendor/github.com/shurcooL/githubv4/.travis.yml |   2 
vendor/github.com/shurcooL/githubv4/README.md   |   8 
vendor/github.com/shurcooL/githubv4/enum.go     | 298 ++++++
vendor/github.com/shurcooL/githubv4/gen.go      |  13 
vendor/github.com/shurcooL/githubv4/input.go    |   1 
30 files changed, 1,898 insertions(+), 64 deletions(-)

Detailed changes

Gopkg.lock 🔗

@@ -214,11 +214,11 @@
 
 [[projects]]
   branch = "master"
-  digest = "1:a1562fb3021983f533a27d02ebf36f1bc1ab327660d611d6e948970b54087792"
+  digest = "1:2befa342040f385b214cfd400887b584d5eba4e4b25a0ebaea839ddb0d59c586"
   name = "github.com/shurcooL/githubv4"
   packages = ["."]
   pruneopts = "UT"
-  revision = "b5f70540eee0ebfb6a27b52fc5b131be76415539"
+  revision = "068505affed7d8555196a48eb3e0ed43410aa8e8"
 
 [[projects]]
   branch = "master"

README.md 🔗

@@ -116,23 +116,31 @@ The web UI interact with the backend through a GraphQL API. The schema is availa
 
 ### Importer implementations
 
-|                                                 | Github             | Launchpad          |
-| ----------------------------------------------- | :----------------: | :----------------: |
-| **incremental**<br/>(can import more than once) | :heavy_check_mark: | :x:                |
-| **with resume**<br/>(download only new data)    | :x:                | :x:                |
-| **identities**                                  | :heavy_check_mark: | :heavy_check_mark: |
-| identities update                               | :x:                | :x:                |
-| **bug**                                         | :heavy_check_mark: | :heavy_check_mark: |
-| comments                                        | :heavy_check_mark: | :heavy_check_mark: |
-| comment editions                                | :heavy_check_mark: | :x:                |
-| labels                                          | :heavy_check_mark: | :x:                |
-| status                                          | :heavy_check_mark: | :x:                |
-| title edition                                   | :heavy_check_mark: | :x:                |
-| **automated test suite**                        | :x:                | :x:                |
+| | Github | Launchpad |
+| --- | --- | --- |
+| **incremental**<br/>(can import more than once) | :heavy_check_mark: | :x: |
+| **with resume**<br/>(download only new data) | :x: | :x: |
+| **identities** | :heavy_check_mark: | :heavy_check_mark: |
+| identities update | :x: | :x: |
+| **bug** | :heavy_check_mark: | :heavy_check_mark: |
+| comments | :heavy_check_mark: | :heavy_check_mark: |
+| comment editions | :heavy_check_mark: | :x: |
+| labels | :heavy_check_mark: | :x: |
+| status | :heavy_check_mark: | :x: |
+| title edition | :heavy_check_mark: | :x: |
+| **automated test suite** | :heavy_check_mark: | :x: |
 
 ### Exporter implementations
 
-Todo !
+| | Github | Launchpad |
+| --- | --- | --- |
+| **bug** | :heavy_check_mark: | :x: |
+| comments | :heavy_check_mark: | :x: |
+| comment editions | :heavy_check_mark: | :x: |
+| labels | :heavy_check_mark: | :x: |
+| status | :heavy_check_mark: | :x: |
+| title edition | :heavy_check_mark: | :x: |
+| **automated test suite** | :heavy_check_mark: | :x: |
 
 ## Internals
 

bridge/core/bridge.go 🔗

@@ -297,20 +297,20 @@ func (b *Bridge) ImportAll(since time.Time) error {
 	return importer.ImportAll(b.repo, since)
 }
 
-func (b *Bridge) ExportAll(since time.Time) error {
+func (b *Bridge) ExportAll(since time.Time) (<-chan ExportResult, error) {
 	exporter := b.getExporter()
 	if exporter == nil {
-		return ErrExportNotSupported
+		return nil, ErrExportNotSupported
 	}
 
 	err := b.ensureConfig()
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	err = b.ensureInit()
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	return exporter.ExportAll(b.repo, since)

bridge/core/export.go 🔗

@@ -0,0 +1,101 @@
+package core
+
+import "fmt"
+
+type ExportEvent int
+
+const (
+	_ ExportEvent = iota
+	ExportEventBug
+	ExportEventComment
+	ExportEventCommentEdition
+	ExportEventStatusChange
+	ExportEventTitleEdition
+	ExportEventLabelChange
+	ExportEventNothing
+)
+
+type ExportResult struct {
+	Err    error
+	Event  ExportEvent
+	ID     string
+	Reason string
+}
+
+func (er ExportResult) String() string {
+	switch er.Event {
+	case ExportEventBug:
+		return "new issue"
+	case ExportEventComment:
+		return "new comment"
+	case ExportEventCommentEdition:
+		return "updated comment"
+	case ExportEventStatusChange:
+		return "changed status"
+	case ExportEventTitleEdition:
+		return "changed title"
+	case ExportEventLabelChange:
+		return "changed label"
+	case ExportEventNothing:
+		return fmt.Sprintf("no event: %v", er.Reason)
+	default:
+		panic("unknown export result")
+	}
+}
+
+func NewExportError(err error, reason string) ExportResult {
+	return ExportResult{
+		Err:    err,
+		Reason: reason,
+	}
+}
+
+func NewExportNothing(id string, reason string) ExportResult {
+	return ExportResult{
+		ID:     id,
+		Reason: reason,
+		Event:  ExportEventNothing,
+	}
+}
+
+func NewExportBug(id string) ExportResult {
+	return ExportResult{
+		ID:    id,
+		Event: ExportEventBug,
+	}
+}
+
+func NewExportComment(id string) ExportResult {
+	return ExportResult{
+		ID:    id,
+		Event: ExportEventComment,
+	}
+}
+
+func NewExportCommentEdition(id string) ExportResult {
+	return ExportResult{
+		ID:    id,
+		Event: ExportEventCommentEdition,
+	}
+}
+
+func NewExportStatusChange(id string) ExportResult {
+	return ExportResult{
+		ID:    id,
+		Event: ExportEventStatusChange,
+	}
+}
+
+func NewExportLabelChange(id string) ExportResult {
+	return ExportResult{
+		ID:    id,
+		Event: ExportEventLabelChange,
+	}
+}
+
+func NewExportTitleEdition(id string) ExportResult {
+	return ExportResult{
+		ID:    id,
+		Event: ExportEventTitleEdition,
+	}
+}

bridge/core/interfaces.go 🔗

@@ -34,5 +34,5 @@ type Importer interface {
 
 type Exporter interface {
 	Init(conf Configuration) error
-	ExportAll(repo *cache.RepoCache, since time.Time) error
+	ExportAll(repo *cache.RepoCache, since time.Time) (<-chan ExportResult, error)
 }

bridge/github/export.go 🔗

@@ -0,0 +1,795 @@
+package github
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/pkg/errors"
+	"github.com/shurcooL/githubv4"
+
+	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/util/git"
+)
+
+var (
+	ErrMissingIdentityToken = errors.New("missing identity token")
+)
+
+// githubExporter implement the Exporter interface
+type githubExporter struct {
+	conf core.Configuration
+
+	// cache identities clients
+	identityClient map[string]*githubv4.Client
+
+	// map identities with their tokens
+	identityToken map[string]string
+
+	// github repository ID
+	repositoryID string
+
+	// cache identifiers used to speed up exporting operations
+	// cleared for each bug
+	cachedOperationIDs map[string]string
+
+	// cache labels used to speed up exporting labels events
+	cachedLabels map[string]string
+}
+
+// Init .
+func (ge *githubExporter) Init(conf core.Configuration) error {
+	ge.conf = conf
+	//TODO: initialize with multiple tokens
+	ge.identityToken = make(map[string]string)
+	ge.identityClient = make(map[string]*githubv4.Client)
+	ge.cachedOperationIDs = make(map[string]string)
+	ge.cachedLabels = make(map[string]string)
+	return nil
+}
+
+// getIdentityClient return a githubv4 API client configured with the access token of the given identity.
+// if no client were found it will initialize it from the known tokens map and cache it for next use
+func (ge *githubExporter) getIdentityClient(id string) (*githubv4.Client, error) {
+	client, ok := ge.identityClient[id]
+	if ok {
+		return client, nil
+	}
+
+	// get token
+	token, ok := ge.identityToken[id]
+	if !ok {
+		return nil, ErrMissingIdentityToken
+	}
+
+	// create client
+	client = buildClient(token)
+	// cache client
+	ge.identityClient[id] = client
+
+	return client, nil
+}
+
+// ExportAll export all event made by the current user to Github
+func (ge *githubExporter) ExportAll(repo *cache.RepoCache, since time.Time) (<-chan core.ExportResult, error) {
+	out := make(chan core.ExportResult)
+
+	user, err := repo.GetUserIdentity()
+	if err != nil {
+		return nil, err
+	}
+
+	ge.identityToken[user.Id()] = ge.conf[keyToken]
+
+	// get repository node id
+	ge.repositoryID, err = getRepositoryNodeID(
+		ge.conf[keyOwner],
+		ge.conf[keyProject],
+		ge.conf[keyToken],
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	go func() {
+		defer close(out)
+
+		var allIdentitiesIds []string
+		for id := range ge.identityToken {
+			allIdentitiesIds = append(allIdentitiesIds, id)
+		}
+
+		allBugsIds := repo.AllBugsIds()
+
+		for _, id := range allBugsIds {
+			b, err := repo.ResolveBug(id)
+			if err != nil {
+				out <- core.NewExportError(err, id)
+				return
+			}
+
+			snapshot := b.Snapshot()
+
+			// ignore issues created before since date
+			// TODO: compare the Lamport time instead of using the unix time
+			if snapshot.CreatedAt.Before(since) {
+				out <- core.NewExportNothing(b.Id(), "bug created before the since date")
+				continue
+			}
+
+			if snapshot.HasAnyActor(allIdentitiesIds...) {
+				// try to export the bug and it associated events
+				ge.exportBug(b, since, out)
+			} else {
+				out <- core.NewExportNothing(id, "not an actor")
+			}
+		}
+	}()
+
+	return out, nil
+}
+
+// exportBug publish bugs and related events
+func (ge *githubExporter) exportBug(b *cache.BugCache, since time.Time, out chan<- core.ExportResult) {
+	snapshot := b.Snapshot()
+
+	var bugGithubID string
+	var bugGithubURL string
+	var bugCreationHash string
+
+	// Special case:
+	// if a user try to export a bug that is not already exported to Github (or imported
+	// from Github) and we do not have the token of the bug author, there is nothing we can do.
+
+	// first operation is always createOp
+	createOp := snapshot.Operations[0].(*bug.CreateOperation)
+	author := snapshot.Author
+
+	// skip bug if origin is not allowed
+	origin, ok := snapshot.GetCreateMetadata(keyOrigin)
+	if ok && origin != target {
+		out <- core.NewExportNothing(b.Id(), fmt.Sprintf("issue tagged with origin: %s", origin))
+		return
+	}
+
+	// get github bug ID
+	githubID, ok := snapshot.GetCreateMetadata(keyGithubId)
+	if ok {
+		githubURL, ok := snapshot.GetCreateMetadata(keyGithubUrl)
+		if !ok {
+			// if we find github ID, github URL must be found too
+			err := fmt.Errorf("expected to find github issue URL")
+			out <- core.NewExportError(err, b.Id())
+		}
+
+		// extract owner and project
+		owner, project, err := splitURL(githubURL)
+		if err != nil {
+			err := fmt.Errorf("bad project url: %v", err)
+			out <- core.NewExportError(err, b.Id())
+			return
+		}
+
+		// ignore issue comming from other repositories
+		if owner != ge.conf[keyOwner] && project != ge.conf[keyProject] {
+			out <- core.NewExportNothing(b.Id(), fmt.Sprintf("skipping issue from url:%s", githubURL))
+			return
+		}
+
+		out <- core.NewExportNothing(b.Id(), "bug already exported")
+		// will be used to mark operation related to a bug as exported
+		bugGithubID = githubID
+		bugGithubURL = githubURL
+
+	} else {
+		// check that we have a token for operation author
+		client, err := ge.getIdentityClient(author.Id())
+		if err != nil {
+			// if bug is still not exported and we do not have the author stop the execution
+			out <- core.NewExportNothing(b.Id(), fmt.Sprintf("missing author token"))
+			return
+		}
+
+		// create bug
+		id, url, err := createGithubIssue(client, ge.repositoryID, createOp.Title, createOp.Message)
+		if err != nil {
+			err := errors.Wrap(err, "exporting github issue")
+			out <- core.NewExportError(err, b.Id())
+			return
+		}
+
+		out <- core.NewExportBug(b.Id())
+
+		hash, err := createOp.Hash()
+		if err != nil {
+			err := errors.Wrap(err, "comment hash")
+			out <- core.NewExportError(err, b.Id())
+			return
+		}
+
+		// mark bug creation operation as exported
+		if err := markOperationAsExported(b, hash, id, url); err != nil {
+			err := errors.Wrap(err, "marking operation as exported")
+			out <- core.NewExportError(err, b.Id())
+			return
+		}
+
+		// commit operation to avoid creating multiple issues with multiple pushes
+		if err := b.CommitAsNeeded(); err != nil {
+			err := errors.Wrap(err, "bug commit")
+			out <- core.NewExportError(err, b.Id())
+			return
+		}
+
+		// cache bug github ID and URL
+		bugGithubID = id
+		bugGithubURL = url
+	}
+
+	// get createOp hash
+	hash, err := createOp.Hash()
+	if err != nil {
+		out <- core.NewExportError(err, b.Id())
+		return
+	}
+
+	bugCreationHash = hash.String()
+
+	// cache operation github id
+	ge.cachedOperationIDs[bugCreationHash] = bugGithubID
+
+	for _, op := range snapshot.Operations[1:] {
+		// ignore SetMetadata operations
+		if _, ok := op.(*bug.SetMetadataOperation); ok {
+			continue
+		}
+
+		// get operation hash
+		hash, err := op.Hash()
+		if err != nil {
+			err := errors.Wrap(err, "operation hash")
+			out <- core.NewExportError(err, b.Id())
+			return
+		}
+
+		// ignore operations already existing in github (due to import or export)
+		// cache the ID of already exported or imported issues and events from Github
+		if id, ok := op.GetMetadata(keyGithubId); ok {
+			ge.cachedOperationIDs[hash.String()] = id
+			out <- core.NewExportNothing(hash.String(), "already exported operation")
+			continue
+		}
+
+		opAuthor := op.GetAuthor()
+		client, err := ge.getIdentityClient(opAuthor.Id())
+		if err != nil {
+			out <- core.NewExportNothing(hash.String(), "missing operation author token")
+			continue
+		}
+
+		var id, url string
+		switch op.(type) {
+		case *bug.AddCommentOperation:
+			opr := op.(*bug.AddCommentOperation)
+
+			// send operation to github
+			id, url, err = addCommentGithubIssue(client, bugGithubID, opr.Message)
+			if err != nil {
+				err := errors.Wrap(err, "adding comment")
+				out <- core.NewExportError(err, b.Id())
+				return
+			}
+
+			out <- core.NewExportComment(hash.String())
+
+			// cache comment id
+			ge.cachedOperationIDs[hash.String()] = id
+
+		case *bug.EditCommentOperation:
+
+			opr := op.(*bug.EditCommentOperation)
+			targetHash := opr.Target.String()
+
+			// Since github doesn't consider the issue body as a comment
+			if targetHash == bugCreationHash {
+
+				// case bug creation operation: we need to edit the Github issue
+				if err := updateGithubIssueBody(client, bugGithubID, opr.Message); err != nil {
+					err := errors.Wrap(err, "editing issue")
+					out <- core.NewExportError(err, b.Id())
+					return
+				}
+
+				out <- core.NewExportCommentEdition(hash.String())
+
+				id = bugGithubID
+				url = bugGithubURL
+
+			} else {
+
+				// case comment edition operation: we need to edit the Github comment
+				commentID, ok := ge.cachedOperationIDs[targetHash]
+				if !ok {
+					panic("unexpected error: comment id not found")
+				}
+
+				eid, eurl, err := editCommentGithubIssue(client, commentID, opr.Message)
+				if err != nil {
+					err := errors.Wrap(err, "editing comment")
+					out <- core.NewExportError(err, b.Id())
+					return
+				}
+
+				out <- core.NewExportCommentEdition(hash.String())
+
+				// use comment id/url instead of issue id/url
+				id = eid
+				url = eurl
+			}
+
+		case *bug.SetStatusOperation:
+			opr := op.(*bug.SetStatusOperation)
+			if err := updateGithubIssueStatus(client, bugGithubID, opr.Status); err != nil {
+				err := errors.Wrap(err, "editing status")
+				out <- core.NewExportError(err, b.Id())
+				return
+			}
+
+			out <- core.NewExportStatusChange(hash.String())
+
+			id = bugGithubID
+			url = bugGithubURL
+
+		case *bug.SetTitleOperation:
+			opr := op.(*bug.SetTitleOperation)
+			if err := updateGithubIssueTitle(client, bugGithubID, opr.Title); err != nil {
+				err := errors.Wrap(err, "editing title")
+				out <- core.NewExportError(err, b.Id())
+				return
+			}
+
+			out <- core.NewExportTitleEdition(hash.String())
+
+			id = bugGithubID
+			url = bugGithubURL
+
+		case *bug.LabelChangeOperation:
+			opr := op.(*bug.LabelChangeOperation)
+			if err := ge.updateGithubIssueLabels(client, bugGithubID, opr.Added, opr.Removed); err != nil {
+				err := errors.Wrap(err, "updating labels")
+				out <- core.NewExportError(err, b.Id())
+				return
+			}
+
+			out <- core.NewExportLabelChange(hash.String())
+
+			id = bugGithubID
+			url = bugGithubURL
+
+		default:
+			panic("unhandled operation type case")
+		}
+
+		// mark operation as exported
+		if err := markOperationAsExported(b, hash, id, url); err != nil {
+			err := errors.Wrap(err, "marking operation as exported")
+			out <- core.NewExportError(err, b.Id())
+			return
+		}
+
+		// commit at each operation export to avoid exporting same events multiple times
+		if err := b.CommitAsNeeded(); err != nil {
+			err := errors.Wrap(err, "bug commit")
+			out <- core.NewExportError(err, b.Id())
+			return
+		}
+	}
+}
+
+// getRepositoryNodeID request github api v3 to get repository node id
+func getRepositoryNodeID(owner, project, token string) (string, error) {
+	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
+
+	client := &http.Client{
+		Timeout: defaultTimeout,
+	}
+
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return "", err
+	}
+
+	// need the token for private repositories
+	req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", err
+	}
+
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("HTTP error %v retrieving repository node id", resp.StatusCode)
+	}
+
+	aux := struct {
+		NodeID string `json:"node_id"`
+	}{}
+
+	data, _ := ioutil.ReadAll(resp.Body)
+	err = resp.Body.Close()
+	if err != nil {
+		return "", err
+	}
+
+	err = json.Unmarshal(data, &aux)
+	if err != nil {
+		return "", err
+	}
+
+	return aux.NodeID, nil
+}
+
+func markOperationAsExported(b *cache.BugCache, target git.Hash, githubID, githubURL string) error {
+	_, err := b.SetMetadata(
+		target,
+		map[string]string{
+			keyGithubId:  githubID,
+			keyGithubUrl: githubURL,
+		},
+	)
+
+	return err
+}
+
+// get label from github
+func (ge *githubExporter) getGithubLabelID(gc *githubv4.Client, label string) (string, error) {
+	q := &labelQuery{}
+	variables := map[string]interface{}{
+		"label": githubv4.String(label),
+		"owner": githubv4.String(ge.conf[keyOwner]),
+		"name":  githubv4.String(ge.conf[keyProject]),
+	}
+
+	parentCtx := context.Background()
+	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
+	defer cancel()
+
+	if err := gc.Query(ctx, q, variables); err != nil {
+		return "", err
+	}
+
+	// if label id is empty, it means there is no such label in this Github repository
+	if q.Repository.Label.ID == "" {
+		return "", fmt.Errorf("label not found")
+	}
+
+	return q.Repository.Label.ID, nil
+}
+
+// create a new label and return it github id
+// NOTE: since createLabel mutation is still in preview mode we use github api v3 to create labels
+// see https://developer.github.com/v4/mutation/createlabel/ and https://developer.github.com/v4/previews/#labels-preview
+func (ge *githubExporter) createGithubLabel(label, color string) (string, error) {
+	url := fmt.Sprintf("%s/repos/%s/%s/labels", githubV3Url, ge.conf[keyOwner], ge.conf[keyProject])
+
+	client := &http.Client{
+		Timeout: defaultTimeout,
+	}
+
+	params := struct {
+		Name        string `json:"name"`
+		Color       string `json:"color"`
+		Description string `json:"description"`
+	}{
+		Name:  label,
+		Color: color,
+	}
+
+	data, err := json.Marshal(params)
+	if err != nil {
+		return "", err
+	}
+
+	req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
+	if err != nil {
+		return "", err
+	}
+
+	// need the token for private repositories
+	req.Header.Set("Authorization", fmt.Sprintf("token %s", ge.conf[keyToken]))
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", err
+	}
+
+	if resp.StatusCode != http.StatusCreated {
+		return "", fmt.Errorf("error creating label: response status %v", resp.StatusCode)
+	}
+
+	aux := struct {
+		ID     int    `json:"id"`
+		NodeID string `json:"node_id"`
+		Color  string `json:"color"`
+	}{}
+
+	data, _ = ioutil.ReadAll(resp.Body)
+	defer resp.Body.Close()
+
+	err = json.Unmarshal(data, &aux)
+	if err != nil {
+		return "", err
+	}
+
+	return aux.NodeID, nil
+}
+
+/**
+// create github label using api v4
+func (ge *githubExporter) createGithubLabelV4(gc *githubv4.Client, label, labelColor string) (string, error) {
+	m := createLabelMutation{}
+	input := createLabelInput{
+		RepositoryID: ge.repositoryID,
+		Name:         githubv4.String(label),
+		Color:        githubv4.String(labelColor),
+	}
+
+	parentCtx := context.Background()
+	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
+	defer cancel()
+
+	if err := gc.Mutate(ctx, &m, input, nil); err != nil {
+		return "", err
+	}
+
+	return m.CreateLabel.Label.ID, nil
+}
+*/
+
+func (ge *githubExporter) getOrCreateGithubLabelID(gc *githubv4.Client, repositoryID string, label bug.Label) (string, error) {
+	// try to get label id
+	labelID, err := ge.getGithubLabelID(gc, string(label))
+	if err == nil {
+		return labelID, nil
+	}
+
+	// RGBA to hex color
+	rgba := label.RGBA()
+	hexColor := fmt.Sprintf("%.2x%.2x%.2x", rgba.R, rgba.G, rgba.B)
+
+	labelID, err = ge.createGithubLabel(string(label), hexColor)
+	if err != nil {
+		return "", err
+	}
+
+	return labelID, nil
+}
+
+func (ge *githubExporter) getLabelsIDs(gc *githubv4.Client, repositoryID string, labels []bug.Label) ([]githubv4.ID, error) {
+	ids := make([]githubv4.ID, 0, len(labels))
+	var err error
+
+	// check labels ids
+	for _, label := range labels {
+		id, ok := ge.cachedLabels[string(label)]
+		if !ok {
+			// try to query label id
+			id, err = ge.getOrCreateGithubLabelID(gc, repositoryID, label)
+			if err != nil {
+				return nil, errors.Wrap(err, "get or create github label")
+			}
+
+			// cache label id
+			ge.cachedLabels[string(label)] = id
+		}
+
+		ids = append(ids, githubv4.ID(id))
+	}
+
+	return ids, nil
+}
+
+// create a github issue and return it ID
+func createGithubIssue(gc *githubv4.Client, repositoryID, title, body string) (string, string, error) {
+	m := &createIssueMutation{}
+	input := githubv4.CreateIssueInput{
+		RepositoryID: repositoryID,
+		Title:        githubv4.String(title),
+		Body:         (*githubv4.String)(&body),
+	}
+
+	parentCtx := context.Background()
+	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
+	defer cancel()
+
+	if err := gc.Mutate(ctx, m, input, nil); err != nil {
+		return "", "", err
+	}
+
+	issue := m.CreateIssue.Issue
+	return issue.ID, issue.URL, nil
+}
+
+// add a comment to an issue and return it ID
+func addCommentGithubIssue(gc *githubv4.Client, subjectID string, body string) (string, string, error) {
+	m := &addCommentToIssueMutation{}
+	input := githubv4.AddCommentInput{
+		SubjectID: subjectID,
+		Body:      githubv4.String(body),
+	}
+
+	parentCtx := context.Background()
+	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
+	defer cancel()
+
+	if err := gc.Mutate(ctx, m, input, nil); err != nil {
+		return "", "", err
+	}
+
+	node := m.AddComment.CommentEdge.Node
+	return node.ID, node.URL, nil
+}
+
+func editCommentGithubIssue(gc *githubv4.Client, commentID, body string) (string, string, error) {
+	m := &updateIssueCommentMutation{}
+	input := githubv4.UpdateIssueCommentInput{
+		ID:   commentID,
+		Body: githubv4.String(body),
+	}
+
+	parentCtx := context.Background()
+	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
+	defer cancel()
+
+	if err := gc.Mutate(ctx, m, input, nil); err != nil {
+		return "", "", err
+	}
+
+	return commentID, m.UpdateIssueComment.IssueComment.URL, nil
+}
+
+func updateGithubIssueStatus(gc *githubv4.Client, id string, status bug.Status) error {
+	m := &updateIssueMutation{}
+
+	// set state
+	var state githubv4.IssueState
+
+	switch status {
+	case bug.OpenStatus:
+		state = githubv4.IssueStateOpen
+	case bug.ClosedStatus:
+		state = githubv4.IssueStateClosed
+	default:
+		panic("unknown bug state")
+	}
+
+	input := githubv4.UpdateIssueInput{
+		ID:    id,
+		State: &state,
+	}
+
+	parentCtx := context.Background()
+	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
+	defer cancel()
+
+	if err := gc.Mutate(ctx, m, input, nil); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func updateGithubIssueBody(gc *githubv4.Client, id string, body string) error {
+	m := &updateIssueMutation{}
+	input := githubv4.UpdateIssueInput{
+		ID:   id,
+		Body: (*githubv4.String)(&body),
+	}
+
+	parentCtx := context.Background()
+	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
+	defer cancel()
+
+	if err := gc.Mutate(ctx, m, input, nil); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func updateGithubIssueTitle(gc *githubv4.Client, id, title string) error {
+	m := &updateIssueMutation{}
+	input := githubv4.UpdateIssueInput{
+		ID:    id,
+		Title: (*githubv4.String)(&title),
+	}
+
+	parentCtx := context.Background()
+	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
+	defer cancel()
+
+	if err := gc.Mutate(ctx, m, input, nil); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// update github issue labels
+func (ge *githubExporter) updateGithubIssueLabels(gc *githubv4.Client, labelableID string, added, removed []bug.Label) error {
+	var errs []string
+	var wg sync.WaitGroup
+
+	parentCtx := context.Background()
+
+	if len(added) > 0 {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+
+			addedIDs, err := ge.getLabelsIDs(gc, labelableID, added)
+			if err != nil {
+				errs = append(errs, errors.Wrap(err, "getting added labels ids").Error())
+				return
+			}
+
+			m := &addLabelsToLabelableMutation{}
+			inputAdd := githubv4.AddLabelsToLabelableInput{
+				LabelableID: labelableID,
+				LabelIDs:    addedIDs,
+			}
+
+			ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
+			defer cancel()
+
+			// add labels
+			if err := gc.Mutate(ctx, m, inputAdd, nil); err != nil {
+				errs = append(errs, err.Error())
+			}
+		}()
+	}
+
+	if len(removed) > 0 {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+
+			removedIDs, err := ge.getLabelsIDs(gc, labelableID, removed)
+			if err != nil {
+				errs = append(errs, errors.Wrap(err, "getting added labels ids").Error())
+				return
+			}
+
+			m2 := &removeLabelsFromLabelableMutation{}
+			inputRemove := githubv4.RemoveLabelsFromLabelableInput{
+				LabelableID: labelableID,
+				LabelIDs:    removedIDs,
+			}
+
+			ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
+			defer cancel()
+
+			// remove label labels
+			if err := gc.Mutate(ctx, m2, inputRemove, nil); err != nil {
+				errs = append(errs, err.Error())
+			}
+		}()
+	}
+
+	wg.Wait()
+
+	if len(errs) == 0 {
+		return nil
+	}
+
+	return fmt.Errorf("label change error: %v", strings.Join(errs, "\n"))
+}

bridge/github/export_mutation.go 🔗

@@ -0,0 +1,74 @@
+package github
+
+type createIssueMutation struct {
+	CreateIssue struct {
+		Issue struct {
+			ID  string `graphql:"id"`
+			URL string `graphql:"url"`
+		}
+	} `graphql:"createIssue(input:$input)"`
+}
+
+type updateIssueMutation struct {
+	UpdateIssue struct {
+		Issue struct {
+			ID  string `graphql:"id"`
+			URL string `graphql:"url"`
+		}
+	} `graphql:"updateIssue(input:$input)"`
+}
+
+type addCommentToIssueMutation struct {
+	AddComment struct {
+		CommentEdge struct {
+			Node struct {
+				ID  string `graphql:"id"`
+				URL string `graphql:"url"`
+			}
+		}
+	} `graphql:"addComment(input:$input)"`
+}
+
+type updateIssueCommentMutation struct {
+	UpdateIssueComment struct {
+		IssueComment struct {
+			ID  string `graphql:"id"`
+			URL string `graphql:"url"`
+		} `graphql:"issueComment"`
+	} `graphql:"updateIssueComment(input:$input)"`
+}
+
+type removeLabelsFromLabelableMutation struct {
+	AddLabels struct {
+		Labelable struct {
+			Typename string `graphql:"__typename"`
+		}
+	} `graphql:"removeLabelsFromLabelable(input:$input)"`
+}
+
+type addLabelsToLabelableMutation struct {
+	RemoveLabels struct {
+		Labelable struct {
+			Typename string `graphql:"__typename"`
+		}
+	} `graphql:"addLabelsToLabelable(input:$input)"`
+}
+
+/**
+type createLabelMutation struct {
+	CreateLabel struct {
+		Label struct {
+			ID string `graphql:"id"`
+		} `graphql:"label"`
+	} `graphql:"createLabel(input: $input)"`
+}
+
+type createLabelInput struct {
+	Color        githubv4.String  `json:"color"`
+	Description  *githubv4.String `json:"description,omitempty"`
+	Name         githubv4.String  `json:"name"`
+	RepositoryID githubv4.ID      `json:"repositoryId"`
+
+	ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"`
+}
+*/

bridge/github/export_test.go 🔗

@@ -0,0 +1,340 @@
+package github
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"math/rand"
+	"net/http"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/repository"
+	"github.com/MichaelMure/git-bug/util/interrupt"
+)
+
+const (
+	testRepoBaseName = "git-bug-test-github-exporter"
+)
+
+type testCase struct {
+	name    string
+	bug     *cache.BugCache
+	numOrOp int // number of original operations
+}
+
+func testCases(t *testing.T, repo *cache.RepoCache, identity *cache.IdentityCache) []*testCase {
+	// simple bug
+	simpleBug, _, err := repo.NewBug("simple bug", "new bug")
+	require.NoError(t, err)
+
+	// bug with comments
+	bugWithComments, _, err := repo.NewBug("bug with comments", "new bug")
+	require.NoError(t, err)
+
+	_, err = bugWithComments.AddComment("new comment")
+	require.NoError(t, err)
+
+	// bug with label changes
+	bugLabelChange, _, err := repo.NewBug("bug label change", "new bug")
+	require.NoError(t, err)
+
+	_, _, err = bugLabelChange.ChangeLabels([]string{"bug"}, nil)
+	require.NoError(t, err)
+
+	_, _, err = bugLabelChange.ChangeLabels([]string{"core"}, nil)
+	require.NoError(t, err)
+
+	_, _, err = bugLabelChange.ChangeLabels(nil, []string{"bug"})
+	require.NoError(t, err)
+
+	// bug with comments editions
+	bugWithCommentEditions, createOp, err := repo.NewBug("bug with comments editions", "new bug")
+	require.NoError(t, err)
+
+	createOpHash, err := createOp.Hash()
+	require.NoError(t, err)
+
+	_, err = bugWithCommentEditions.EditComment(createOpHash, "first comment edited")
+	require.NoError(t, err)
+
+	commentOp, err := bugWithCommentEditions.AddComment("first comment")
+	require.NoError(t, err)
+
+	commentOpHash, err := commentOp.Hash()
+	require.NoError(t, err)
+
+	_, err = bugWithCommentEditions.EditComment(commentOpHash, "first comment edited")
+	require.NoError(t, err)
+
+	// bug status changed
+	bugStatusChanged, _, err := repo.NewBug("bug status changed", "new bug")
+	require.NoError(t, err)
+
+	_, err = bugStatusChanged.Close()
+	require.NoError(t, err)
+
+	_, err = bugStatusChanged.Open()
+	require.NoError(t, err)
+
+	// bug title changed
+	bugTitleEdited, _, err := repo.NewBug("bug title edited", "new bug")
+	require.NoError(t, err)
+
+	_, err = bugTitleEdited.SetTitle("bug title edited again")
+	require.NoError(t, err)
+
+	return []*testCase{
+		&testCase{
+			name:    "simple bug",
+			bug:     simpleBug,
+			numOrOp: 1,
+		},
+		&testCase{
+			name:    "bug with comments",
+			bug:     bugWithComments,
+			numOrOp: 2,
+		},
+		&testCase{
+			name:    "bug label change",
+			bug:     bugLabelChange,
+			numOrOp: 4,
+		},
+		&testCase{
+			name:    "bug with comment editions",
+			bug:     bugWithCommentEditions,
+			numOrOp: 4,
+		},
+		&testCase{
+			name:    "bug changed status",
+			bug:     bugStatusChanged,
+			numOrOp: 3,
+		},
+		&testCase{
+			name:    "bug title edited",
+			bug:     bugTitleEdited,
+			numOrOp: 2,
+		},
+	}
+}
+
+func TestPushPull(t *testing.T) {
+	// repo owner
+	user := os.Getenv("GITHUB_TEST_USER")
+
+	// token must have 'repo' and 'delete_repo' scopes
+	token := os.Getenv("GITHUB_TOKEN_ADMIN")
+	if token == "" {
+		t.Skip("Env var GITHUB_TOKEN_ADMIN missing")
+	}
+
+	// create repo backend
+	repo := repository.CreateTestRepo(false)
+	defer repository.CleanupTestRepos(t, repo)
+
+	backend, err := cache.NewRepoCache(repo)
+	require.NoError(t, err)
+
+	// set author identity
+	author, err := backend.NewIdentity("test identity", "test@test.org")
+	require.NoError(t, err)
+
+	err = backend.SetUserIdentity(author)
+	require.NoError(t, err)
+
+	defer backend.Close()
+	interrupt.RegisterCleaner(backend.Close)
+
+	tests := testCases(t, backend, author)
+
+	// generate project name
+	projectName := generateRepoName()
+
+	// create target Github repository
+	err = createRepository(projectName, token)
+	require.NoError(t, err)
+
+	fmt.Println("created repository", projectName)
+
+	// Make sure to remove the Github repository when the test end
+	defer func(t *testing.T) {
+		if err := deleteRepository(projectName, user, token); err != nil {
+			t.Fatal(err)
+		}
+		fmt.Println("deleted repository:", projectName)
+	}(t)
+
+	interrupt.RegisterCleaner(func() error {
+		return deleteRepository(projectName, user, token)
+	})
+
+	// initialize exporter
+	exporter := &githubExporter{}
+	err = exporter.Init(core.Configuration{
+		keyOwner:   user,
+		keyProject: projectName,
+		keyToken:   token,
+	})
+	require.NoError(t, err)
+
+	start := time.Now()
+
+	// export all bugs
+	events, err := exporter.ExportAll(backend, time.Time{})
+	require.NoError(t, err)
+
+	for result := range events {
+		require.NoError(t, result.Err)
+	}
+	require.NoError(t, err)
+
+	fmt.Printf("test repository exported in %f seconds\n", time.Since(start).Seconds())
+
+	repoTwo := repository.CreateTestRepo(false)
+	defer repository.CleanupTestRepos(t, repoTwo)
+
+	// create a second backend
+	backendTwo, err := cache.NewRepoCache(repoTwo)
+	require.NoError(t, err)
+
+	importer := &githubImporter{}
+	err = importer.Init(core.Configuration{
+		keyOwner:   user,
+		keyProject: projectName,
+		keyToken:   token,
+	})
+	require.NoError(t, err)
+
+	// import all exported bugs to the second backend
+	err = importer.ImportAll(backendTwo, time.Time{})
+	require.NoError(t, err)
+
+	require.Len(t, backendTwo.AllBugsIds(), len(tests))
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// for each operation a SetMetadataOperation will be added
+			// so number of operations should double
+			require.Len(t, tt.bug.Snapshot().Operations, tt.numOrOp*2)
+
+			// verify operation have correct metadata
+			for _, op := range tt.bug.Snapshot().Operations {
+				// Check if the originals operations (*not* SetMetadata) are tagged properly
+				if _, ok := op.(*bug.SetMetadataOperation); !ok {
+					_, haveIDMetadata := op.GetMetadata(keyGithubId)
+					require.True(t, haveIDMetadata)
+
+					_, haveURLMetada := op.GetMetadata(keyGithubUrl)
+					require.True(t, haveURLMetada)
+				}
+			}
+
+			// get bug github ID
+			bugGithubID, ok := tt.bug.Snapshot().GetCreateMetadata(keyGithubId)
+			require.True(t, ok)
+
+			// retrieve bug from backendTwo
+			importedBug, err := backendTwo.ResolveBugCreateMetadata(keyGithubId, bugGithubID)
+			require.NoError(t, err)
+
+			// verify bug have same number of original operations
+			require.Len(t, importedBug.Snapshot().Operations, tt.numOrOp)
+
+			// verify bugs are taged with origin=github
+			issueOrigin, ok := importedBug.Snapshot().GetCreateMetadata(keyOrigin)
+			require.True(t, ok)
+			require.Equal(t, issueOrigin, target)
+
+			//TODO: maybe more tests to ensure bug final state
+		})
+	}
+}
+
+func generateRepoName() string {
+	rand.Seed(time.Now().UnixNano())
+	var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
+	b := make([]rune, 8)
+	for i := range b {
+		b[i] = letterRunes[rand.Intn(len(letterRunes))]
+	}
+	return fmt.Sprintf("%s-%s", testRepoBaseName, string(b))
+}
+
+// create repository need a token with scope 'repo'
+func createRepository(project, token string) error {
+	// This function use the V3 Github API because repository creation is not supported yet on the V4 API.
+	url := fmt.Sprintf("%s/user/repos", githubV3Url)
+
+	params := struct {
+		Name        string `json:"name"`
+		Description string `json:"description"`
+		Private     bool   `json:"private"`
+		HasIssues   bool   `json:"has_issues"`
+	}{
+		Name:        project,
+		Description: "git-bug exporter temporary test repository",
+		Private:     true,
+		HasIssues:   true,
+	}
+
+	data, err := json.Marshal(params)
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
+	if err != nil {
+		return err
+	}
+
+	// need the token for private repositories
+	req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
+
+	client := &http.Client{
+		Timeout: defaultTimeout,
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+
+	return resp.Body.Close()
+}
+
+// delete repository need a token with scope 'delete_repo'
+func deleteRepository(project, owner, token string) error {
+	// This function use the V3 Github API because repository removal is not supported yet on the V4 API.
+	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
+
+	req, err := http.NewRequest("DELETE", url, nil)
+	if err != nil {
+		return err
+	}
+
+	// need the token for private repositories
+	req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
+
+	client := &http.Client{
+		Timeout: defaultTimeout,
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusNoContent {
+		return fmt.Errorf("error deleting repository")
+	}
+
+	return nil
+}

bridge/github/github.go 🔗

@@ -17,7 +17,7 @@ func init() {
 type Github struct{}
 
 func (*Github) Target() string {
-	return "github"
+	return target
 }
 
 func (*Github) NewImporter() core.Importer {
@@ -25,7 +25,7 @@ func (*Github) NewImporter() core.Importer {
 }
 
 func (*Github) NewExporter() core.Exporter {
-	return nil
+	return &githubExporter{}
 }
 
 func buildClient(token string) *githubv4.Client {

bridge/github/import.go 🔗

@@ -16,6 +16,7 @@ import (
 )
 
 const (
+	keyOrigin      = "origin"
 	keyGithubId    = "github-id"
 	keyGithubUrl   = "github-url"
 	keyGithubLogin = "github-login"
@@ -113,6 +114,7 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline
 				cleanText,
 				nil,
 				map[string]string{
+					keyOrigin:    target,
 					keyGithubId:  parseId(issue.Id),
 					keyGithubUrl: issue.Url.String(),
 				})
@@ -147,6 +149,7 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline
 					cleanText,
 					nil,
 					map[string]string{
+						keyOrigin:    target,
 						keyGithubId:  parseId(issue.Id),
 						keyGithubUrl: issue.Url.String(),
 					},
@@ -502,7 +505,7 @@ func (gi *githubImporter) getGhost(repo *cache.RepoCache) (*cache.IdentityCache,
 		return nil, err
 	}
 
-	var q userQuery
+	var q ghostQuery
 
 	variables := map[string]interface{}{
 		"login": githubv4.String("ghost"),
@@ -510,7 +513,11 @@ func (gi *githubImporter) getGhost(repo *cache.RepoCache) (*cache.IdentityCache,
 
 	gc := buildClient(gi.conf[keyToken])
 
-	err = gc.Query(context.TODO(), &q, variables)
+	parentCtx := context.Background()
+	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
+	defer cancel()
+
+	err = gc.Query(ctx, &q, variables)
 	if err != nil {
 		return nil, err
 	}
@@ -522,7 +529,7 @@ func (gi *githubImporter) getGhost(repo *cache.RepoCache) (*cache.IdentityCache,
 
 	return repo.NewIdentityRaw(
 		name,
-		string(q.User.Email),
+		"",
 		string(q.User.Login),
 		string(q.User.AvatarUrl),
 		map[string]string{

bridge/github/import_query.go 🔗

@@ -160,11 +160,18 @@ type commentEditQuery struct {
 	} `graphql:"repository(owner: $owner, name: $name)"`
 }
 
-type userQuery struct {
+type ghostQuery struct {
 	User struct {
 		Login     githubv4.String
 		AvatarUrl githubv4.String
 		Name      *githubv4.String
-		Email     githubv4.String
 	} `graphql:"user(login: $login)"`
 }
+
+type labelQuery struct {
+	Repository struct {
+		Label struct {
+			ID string `graphql:"id"`
+		} `graphql:"label(name: $label)"`
+	} `graphql:"repository(owner: $owner, name: $name)"`
+}

bridge/github/import_test.go 🔗

@@ -190,7 +190,7 @@ func Test_Importer(t *testing.T) {
 					assert.Equal(t, op.(*bug.EditCommentOperation).Author.Name(), ops[i].(*bug.EditCommentOperation).Author.Name())
 
 				default:
-					panic("Unknown operation type")
+					panic("unknown operation type")
 				}
 			}
 		})

bridge/github/iterator.go 🔗

@@ -59,7 +59,7 @@ type iterator struct {
 	commentEdit commentEditIterator
 }
 
-// NewIterator create and initalize a new iterator
+// NewIterator create and initialize a new iterator
 func NewIterator(owner, project, token string, since time.Time) *iterator {
 	i := &iterator{
 		gc:       buildClient(token),
@@ -147,7 +147,11 @@ func (i *iterator) Error() error {
 }
 
 func (i *iterator) queryIssue() bool {
-	if err := i.gc.Query(context.TODO(), &i.timeline.query, i.timeline.variables); err != nil {
+	parentCtx := context.Background()
+	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
+	defer cancel()
+
+	if err := i.gc.Query(ctx, &i.timeline.query, i.timeline.variables); err != nil {
 		i.err = err
 		return false
 	}
@@ -220,7 +224,12 @@ func (i *iterator) NextTimelineItem() bool {
 
 	// more timelines, query them
 	i.timeline.variables["timelineAfter"] = i.timeline.query.Repository.Issues.Nodes[0].Timeline.PageInfo.EndCursor
-	if err := i.gc.Query(context.TODO(), &i.timeline.query, i.timeline.variables); err != nil {
+
+	parentCtx := context.Background()
+	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
+	defer cancel()
+
+	if err := i.gc.Query(ctx, &i.timeline.query, i.timeline.variables); err != nil {
 		i.err = err
 		return false
 	}
@@ -236,7 +245,11 @@ func (i *iterator) TimelineItemValue() timelineItem {
 }
 
 func (i *iterator) queryIssueEdit() bool {
-	if err := i.gc.Query(context.TODO(), &i.issueEdit.query, i.issueEdit.variables); err != nil {
+	parentCtx := context.Background()
+	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
+	defer cancel()
+
+	if err := i.gc.Query(ctx, &i.issueEdit.query, i.issueEdit.variables); err != nil {
 		i.err = err
 		//i.timeline.issueEdit.index = -1
 		return false
@@ -334,7 +347,11 @@ func (i *iterator) IssueEditValue() userContentEdit {
 }
 
 func (i *iterator) queryCommentEdit() bool {
-	if err := i.gc.Query(context.TODO(), &i.commentEdit.query, i.commentEdit.variables); err != nil {
+	parentCtx := context.Background()
+	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
+	defer cancel()
+
+	if err := i.gc.Query(ctx, &i.commentEdit.query, i.commentEdit.variables); err != nil {
 		i.err = err
 		return false
 	}

bug/operation.go 🔗

@@ -49,6 +49,8 @@ type Operation interface {
 	GetMetadata(key string) (string, bool)
 	// AllMetadata return all metadata for this operation
 	AllMetadata() map[string]string
+	// GetAuthor return the author identity
+	GetAuthor() identity.Interface
 }
 
 func hashRaw(data []byte) git.Hash {
@@ -222,3 +224,8 @@ func (op *OpBase) AllMetadata() map[string]string {
 
 	return result
 }
+
+// GetAuthor return author identity
+func (op *OpBase) GetAuthor() identity.Interface {
+	return op.Author
+}

bug/snapshot.go 🔗

@@ -54,6 +54,11 @@ func (snap *Snapshot) LastEditUnix() int64 {
 	return snap.Operations[len(snap.Operations)-1].GetUnixTime()
 }
 
+// GetCreateMetadata return the creation metadata
+func (snap *Snapshot) GetCreateMetadata(key string) (string, bool) {
+	return snap.Operations[0].GetMetadata(key)
+}
+
 // SearchTimelineItem will search in the timeline for an item matching the given hash
 func (snap *Snapshot) SearchTimelineItem(hash git.Hash) (TimelineItem, error) {
 	for i := range snap.Timeline {
@@ -87,5 +92,45 @@ func (snap *Snapshot) addParticipant(participant identity.Interface) {
 	snap.Participants = append(snap.Participants, participant)
 }
 
+// HasParticipant return true if the id is a participant
+func (snap *Snapshot) HasParticipant(id string) bool {
+	for _, p := range snap.Participants {
+		if p.Id() == id {
+			return true
+		}
+	}
+	return false
+}
+
+// HasAnyParticipant return true if one of the ids is a participant
+func (snap *Snapshot) HasAnyParticipant(ids ...string) bool {
+	for _, id := range ids {
+		if snap.HasParticipant(id) {
+			return true
+		}
+	}
+	return false
+}
+
+// HasActor return true if the id is a actor
+func (snap *Snapshot) HasActor(id string) bool {
+	for _, p := range snap.Actors {
+		if p.Id() == id {
+			return true
+		}
+	}
+	return false
+}
+
+// HasAnyActor return true if one of the ids is a actor
+func (snap *Snapshot) HasAnyActor(ids ...string) bool {
+	for _, id := range ids {
+		if snap.HasActor(id) {
+			return true
+		}
+	}
+	return false
+}
+
 // Sign post method for gqlgen
 func (snap *Snapshot) IsAuthored() {}

cache/bug_cache.go 🔗

@@ -254,6 +254,24 @@ func (c *BugCache) EditCommentRaw(author *IdentityCache, unixTime int64, target
 	return op, c.notifyUpdated()
 }
 
+func (c *BugCache) SetMetadata(target git.Hash, newMetadata map[string]string) (*bug.SetMetadataOperation, error) {
+	author, err := c.repoCache.GetUserIdentity()
+	if err != nil {
+		return nil, err
+	}
+
+	return c.SetMetadataRaw(author, time.Now().Unix(), target, newMetadata)
+}
+
+func (c *BugCache) SetMetadataRaw(author *IdentityCache, unixTime int64, target git.Hash, newMetadata map[string]string) (*bug.SetMetadataOperation, error) {
+	op, err := bug.SetMetadata(c.bug, author.Identity, unixTime, target, newMetadata)
+	if err != nil {
+		return nil, err
+	}
+
+	return op, c.notifyUpdated()
+}
+
 func (c *BugCache) Commit() error {
 	err := c.bug.Commit(c.repoCache.repo)
 	if err != nil {

commands/bridge_pull.go 🔗

@@ -3,11 +3,12 @@ package commands
 import (
 	"time"
 
+	"github.com/spf13/cobra"
+
 	"github.com/MichaelMure/git-bug/bridge"
 	"github.com/MichaelMure/git-bug/bridge/core"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/util/interrupt"
-	"github.com/spf13/cobra"
 )
 
 func runBridgePull(cmd *cobra.Command, args []string) error {
@@ -44,6 +45,7 @@ var bridgePullCmd = &cobra.Command{
 	Short:   "Pull updates.",
 	PreRunE: loadRepo,
 	RunE:    runBridgePull,
+	Args:    cobra.MaximumNArgs(1),
 }
 
 func init() {

commands/bridge_push.go 🔗

@@ -0,0 +1,62 @@
+package commands
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/spf13/cobra"
+
+	"github.com/MichaelMure/git-bug/bridge"
+	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/util/interrupt"
+)
+
+func runBridgePush(cmd *cobra.Command, args []string) error {
+	backend, err := cache.NewRepoCache(repo)
+	if err != nil {
+		return err
+	}
+	defer backend.Close()
+	interrupt.RegisterCleaner(backend.Close)
+
+	var b *core.Bridge
+
+	if len(args) == 0 {
+		b, err = bridge.DefaultBridge(backend)
+	} else {
+		b, err = bridge.LoadBridge(backend, args[0])
+	}
+
+	if err != nil {
+		return err
+	}
+
+	// TODO: by default export only new events
+	out, err := b.ExportAll(time.Time{})
+	if err != nil {
+		return err
+	}
+
+	for result := range out {
+		if result.Err != nil {
+			fmt.Println(result.Err, result.Reason)
+		} else {
+			fmt.Printf("%s: %s\n", result.String(), result.ID)
+		}
+	}
+
+	return nil
+}
+
+var bridgePushCmd = &cobra.Command{
+	Use:     "push [<name>]",
+	Short:   "Push updates.",
+	PreRunE: loadRepo,
+	RunE:    runBridgePush,
+	Args:    cobra.MaximumNArgs(1),
+}
+
+func init() {
+	bridgeCmd.AddCommand(bridgePushCmd)
+}

doc/man/git-bug-bridge-push.1 🔗

@@ -0,0 +1,29 @@
+.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" 
+.nh
+.ad l
+
+
+.SH NAME
+.PP
+git\-bug\-bridge\-push \- Push updates.
+
+
+.SH SYNOPSIS
+.PP
+\fBgit\-bug bridge push [<name>] [flags]\fP
+
+
+.SH DESCRIPTION
+.PP
+Push updates.
+
+
+.SH OPTIONS
+.PP
+\fB\-h\fP, \fB\-\-help\fP[=false]
+    help for push
+
+
+.SH SEE ALSO
+.PP
+\fBgit\-bug\-bridge(1)\fP

doc/man/git-bug-bridge.1 🔗

@@ -26,4 +26,4 @@ Configure and use bridges to other bug trackers.
 
 .SH SEE ALSO
 .PP
-\fBgit\-bug(1)\fP, \fBgit\-bug\-bridge\-configure(1)\fP, \fBgit\-bug\-bridge\-pull(1)\fP, \fBgit\-bug\-bridge\-rm(1)\fP
+\fBgit\-bug(1)\fP, \fBgit\-bug\-bridge\-configure(1)\fP, \fBgit\-bug\-bridge\-pull(1)\fP, \fBgit\-bug\-bridge\-push(1)\fP, \fBgit\-bug\-bridge\-rm(1)\fP

doc/md/git-bug_bridge.md 🔗

@@ -21,5 +21,6 @@ git-bug bridge [flags]
 * [git-bug](git-bug.md)	 - A bug tracker embedded in Git.
 * [git-bug bridge configure](git-bug_bridge_configure.md)	 - Configure a new bridge.
 * [git-bug bridge pull](git-bug_bridge_pull.md)	 - Pull updates.
+* [git-bug bridge push](git-bug_bridge_push.md)	 - Push updates.
 * [git-bug bridge rm](git-bug_bridge_rm.md)	 - Delete a configured bridge.
 

doc/md/git-bug_bridge_push.md 🔗

@@ -0,0 +1,22 @@
+## git-bug bridge push
+
+Push updates.
+
+### Synopsis
+
+Push updates.
+
+```
+git-bug bridge push [<name>] [flags]
+```
+
+### Options
+
+```
+  -h, --help   help for push
+```
+
+### SEE ALSO
+
+* [git-bug bridge](git-bug_bridge.md)	 - Configure and use bridges to other bug trackers.
+

misc/bash_completion/git-bug 🔗

@@ -351,6 +351,26 @@ _git-bug_bridge_pull()
     noun_aliases=()
 }
 
+_git-bug_bridge_push()
+{
+    last_command="git-bug_bridge_push"
+
+    command_aliases=()
+
+    commands=()
+
+    flags=()
+    two_word_flags=()
+    local_nonpersistent_flags=()
+    flags_with_completion=()
+    flags_completion=()
+
+
+    must_have_one_flag=()
+    must_have_one_noun=()
+    noun_aliases=()
+}
+
 _git-bug_bridge_rm()
 {
     last_command="git-bug_bridge_rm"
@@ -380,6 +400,7 @@ _git-bug_bridge()
     commands=()
     commands+=("configure")
     commands+=("pull")
+    commands+=("push")
     commands+=("rm")
 
     flags=()

misc/powershell_completion/git-bug 🔗

@@ -50,6 +50,7 @@ Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock {
         'git-bug;bridge' {
             [CompletionResult]::new('configure', 'configure', [CompletionResultType]::ParameterValue, 'Configure a new bridge.')
             [CompletionResult]::new('pull', 'pull', [CompletionResultType]::ParameterValue, 'Pull updates.')
+            [CompletionResult]::new('push', 'push', [CompletionResultType]::ParameterValue, 'Push updates.')
             [CompletionResult]::new('rm', 'rm', [CompletionResultType]::ParameterValue, 'Delete a configured bridge.')
             break
         }
@@ -71,6 +72,9 @@ Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock {
         'git-bug;bridge;pull' {
             break
         }
+        'git-bug;bridge;push' {
+            break
+        }
         'git-bug;bridge;rm' {
             break
         }

misc/zsh_completion/git-bug 🔗

@@ -116,6 +116,7 @@ function _git-bug_bridge {
     commands=(
       "configure:Configure a new bridge."
       "pull:Pull updates."
+      "push:Push updates."
       "rm:Delete a configured bridge."
     )
     _describe "command" commands
@@ -129,6 +130,9 @@ function _git-bug_bridge {
   pull)
     _git-bug_bridge_pull
     ;;
+  push)
+    _git-bug_bridge_push
+    ;;
   rm)
     _git-bug_bridge_rm
     ;;
@@ -149,6 +153,10 @@ function _git-bug_bridge_pull {
   _arguments
 }
 
+function _git-bug_bridge_push {
+  _arguments
+}
+
 function _git-bug_bridge_rm {
   _arguments
 }

vendor/github.com/shurcooL/githubv4/README.md 🔗

@@ -48,7 +48,7 @@ func main() {
 
 ### Simple Query
 
-To make a query, you need to define a Go type that corresponds to the GitHub GraphQL schema, and contains the fields you're interested in querying. You can look up the GitHub GraphQL schema at https://developer.github.com/v4/reference/query/.
+To make a query, you need to define a Go type that corresponds to the GitHub GraphQL schema, and contains the fields you're interested in querying. You can look up the GitHub GraphQL schema at https://developer.github.com/v4/query/.
 
 For example, to make the following GraphQL query:
 
@@ -89,7 +89,7 @@ fmt.Println("CreatedAt:", query.Viewer.CreatedAt)
 
 ### Scalar Types
 
-For each scalar in the GitHub GraphQL schema listed at https://developer.github.com/v4/reference/scalar/, there is a corresponding Go type in package `githubv4`.
+For each scalar in the GitHub GraphQL schema listed at https://developer.github.com/v4/scalar/, there is a corresponding Go type in package `githubv4`.
 
 You can use these types when writing queries:
 
@@ -127,7 +127,7 @@ var query struct {
 // Call client.Query() and use results in query...
 ```
 
-The [`DateTime`](https://developer.github.com/v4/reference/scalar/datetime/) scalar is described as "an ISO-8601 encoded UTC date string". If you wanted to fetch in that form without parsing it into a `time.Time`, you can use the `string` type. For example, this would work:
+The [`DateTime`](https://developer.github.com/v4/scalar/datetime/) scalar is described as "an ISO-8601 encoded UTC date string". If you wanted to fetch in that form without parsing it into a `time.Time`, you can use the `string` type. For example, this would work:
 
 ```Go
 // import "html/template"
@@ -336,7 +336,7 @@ for {
 }
 ```
 
-There is more than one way to perform pagination. Consider additional fields inside [`PageInfo`](https://developer.github.com/v4/reference/object/pageinfo/) object.
+There is more than one way to perform pagination. Consider additional fields inside [`PageInfo`](https://developer.github.com/v4/object/pageinfo/) object.
 
 ### Mutations
 

vendor/github.com/shurcooL/githubv4/enum.go 🔗

@@ -36,6 +36,24 @@ const (
 	CommentCannotUpdateReasonLoginRequired         CommentCannotUpdateReason = "LOGIN_REQUIRED"          // You must be logged in to update this comment.
 	CommentCannotUpdateReasonMaintenance           CommentCannotUpdateReason = "MAINTENANCE"             // Repository is under maintenance.
 	CommentCannotUpdateReasonVerifiedEmailRequired CommentCannotUpdateReason = "VERIFIED_EMAIL_REQUIRED" // At least one email address must be verified to update this comment.
+	CommentCannotUpdateReasonDenied                CommentCannotUpdateReason = "DENIED"                  // You cannot update this comment.
+)
+
+// CommitContributionOrderField represents properties by which commit contribution connections can be ordered.
+type CommitContributionOrderField string
+
+// Properties by which commit contribution connections can be ordered.
+const (
+	CommitContributionOrderFieldOccurredAt  CommitContributionOrderField = "OCCURRED_AT"  // Order commit contributions by when they were made.
+	CommitContributionOrderFieldCommitCount CommitContributionOrderField = "COMMIT_COUNT" // Order commit contributions by how many commits they represent.
+)
+
+// ContributionOrderField represents properties by which contribution connections can be ordered.
+type ContributionOrderField string
+
+// Properties by which contribution connections can be ordered.
+const (
+	ContributionOrderFieldOccurredAt ContributionOrderField = "OCCURRED_AT" // Order contributions by when they were made.
 )
 
 // DefaultRepositoryPermissionField represents the possible default permissions for repositories.
@@ -49,18 +67,28 @@ const (
 	DefaultRepositoryPermissionFieldAdmin DefaultRepositoryPermissionField = "ADMIN" // Can read, write, and administrate repos by default.
 )
 
+// DeploymentOrderField represents properties by which deployment connections can be ordered.
+type DeploymentOrderField string
+
+// Properties by which deployment connections can be ordered.
+const (
+	DeploymentOrderFieldCreatedAt DeploymentOrderField = "CREATED_AT" // Order collection by creation time.
+)
+
 // DeploymentState represents the possible states in which a deployment can be.
 type DeploymentState string
 
 // The possible states in which a deployment can be.
 const (
-	DeploymentStateAbandoned DeploymentState = "ABANDONED" // The pending deployment was not updated after 30 minutes.
-	DeploymentStateActive    DeploymentState = "ACTIVE"    // The deployment is currently active.
-	DeploymentStateDestroyed DeploymentState = "DESTROYED" // An inactive transient deployment.
-	DeploymentStateError     DeploymentState = "ERROR"     // The deployment experienced an error.
-	DeploymentStateFailure   DeploymentState = "FAILURE"   // The deployment has failed.
-	DeploymentStateInactive  DeploymentState = "INACTIVE"  // The deployment is inactive.
-	DeploymentStatePending   DeploymentState = "PENDING"   // The deployment is pending.
+	DeploymentStateAbandoned  DeploymentState = "ABANDONED"   // The pending deployment was not updated after 30 minutes.
+	DeploymentStateActive     DeploymentState = "ACTIVE"      // The deployment is currently active.
+	DeploymentStateDestroyed  DeploymentState = "DESTROYED"   // An inactive transient deployment.
+	DeploymentStateError      DeploymentState = "ERROR"       // The deployment experienced an error.
+	DeploymentStateFailure    DeploymentState = "FAILURE"     // The deployment has failed.
+	DeploymentStateInactive   DeploymentState = "INACTIVE"    // The deployment is inactive.
+	DeploymentStatePending    DeploymentState = "PENDING"     // The deployment is pending.
+	DeploymentStateQueued     DeploymentState = "QUEUED"      // The deployment has queued.
+	DeploymentStateInProgress DeploymentState = "IN_PROGRESS" // The deployment is in progress.
 )
 
 // DeploymentStatusState represents the possible states for a deployment status.
@@ -68,11 +96,13 @@ type DeploymentStatusState string
 
 // The possible states for a deployment status.
 const (
-	DeploymentStatusStatePending  DeploymentStatusState = "PENDING"  // The deployment is pending.
-	DeploymentStatusStateSuccess  DeploymentStatusState = "SUCCESS"  // The deployment was successful.
-	DeploymentStatusStateFailure  DeploymentStatusState = "FAILURE"  // The deployment has failed.
-	DeploymentStatusStateInactive DeploymentStatusState = "INACTIVE" // The deployment is inactive.
-	DeploymentStatusStateError    DeploymentStatusState = "ERROR"    // The deployment experienced an error.
+	DeploymentStatusStatePending    DeploymentStatusState = "PENDING"     // The deployment is pending.
+	DeploymentStatusStateSuccess    DeploymentStatusState = "SUCCESS"     // The deployment was successful.
+	DeploymentStatusStateFailure    DeploymentStatusState = "FAILURE"     // The deployment has failed.
+	DeploymentStatusStateInactive   DeploymentStatusState = "INACTIVE"    // The deployment is inactive.
+	DeploymentStatusStateError      DeploymentStatusState = "ERROR"       // The deployment experienced an error.
+	DeploymentStatusStateQueued     DeploymentStatusState = "QUEUED"      // The deployment is queued.
+	DeploymentStatusStateInProgress DeploymentStatusState = "IN_PROGRESS" // The deployment is in progress.
 )
 
 // GistOrderField represents properties by which gist connections can be ordered.
@@ -115,9 +145,20 @@ const (
 	GitSignatureStateExpiredKey           GitSignatureState = "EXPIRED_KEY"           // Signing key expired.
 	GitSignatureStateOcspPending          GitSignatureState = "OCSP_PENDING"          // Valid signature, pending certificate revocation checking.
 	GitSignatureStateOcspError            GitSignatureState = "OCSP_ERROR"            // Valid siganture, though certificate revocation check failed.
+	GitSignatureStateBadCert              GitSignatureState = "BAD_CERT"              // The signing certificate or its chain could not be verified.
 	GitSignatureStateOcspRevoked          GitSignatureState = "OCSP_REVOKED"          // One or more certificates in chain has been revoked.
 )
 
+// IdentityProviderConfigurationState represents the possible states in which authentication can be configured with an identity provider.
+type IdentityProviderConfigurationState string
+
+// The possible states in which authentication can be configured with an identity provider.
+const (
+	IdentityProviderConfigurationStateEnforced     IdentityProviderConfigurationState = "ENFORCED"     // Authentication with an identity provider is configured and enforced.
+	IdentityProviderConfigurationStateConfigured   IdentityProviderConfigurationState = "CONFIGURED"   // Authentication with an identity provider is configured but not enforced.
+	IdentityProviderConfigurationStateUnconfigured IdentityProviderConfigurationState = "UNCONFIGURED" // Authentication with an identity provider is not configured.
+)
+
 // IssueOrderField represents properties by which issue connections can be ordered.
 type IssueOrderField string
 
@@ -136,6 +177,7 @@ const (
 	IssuePubSubTopicUpdated    IssuePubSubTopic = "UPDATED"    // The channel ID for observing issue updates.
 	IssuePubSubTopicMarkasread IssuePubSubTopic = "MARKASREAD" // The channel ID for marking an issue as read.
 	IssuePubSubTopicTimeline   IssuePubSubTopic = "TIMELINE"   // The channel ID for updating items on the issue timeline.
+	IssuePubSubTopicState      IssuePubSubTopic = "STATE"      // The channel ID for observing issue state updates.
 )
 
 // IssueState represents the possible states of an issue.
@@ -147,6 +189,39 @@ const (
 	IssueStateClosed IssueState = "CLOSED" // An issue that has been closed.
 )
 
+// IssueTimelineItemsItemType represents the possible item types found in a timeline.
+type IssueTimelineItemsItemType string
+
+// The possible item types found in a timeline.
+const (
+	IssueTimelineItemsItemTypeIssueComment               IssueTimelineItemsItemType = "ISSUE_COMMENT"                  // Represents a comment on an Issue.
+	IssueTimelineItemsItemTypeCrossReferencedEvent       IssueTimelineItemsItemType = "CROSS_REFERENCED_EVENT"         // Represents a mention made by one issue or pull request to another.
+	IssueTimelineItemsItemTypeAddedToProjectEvent        IssueTimelineItemsItemType = "ADDED_TO_PROJECT_EVENT"         // Represents a 'added_to_project' event on a given issue or pull request.
+	IssueTimelineItemsItemTypeAssignedEvent              IssueTimelineItemsItemType = "ASSIGNED_EVENT"                 // Represents an 'assigned' event on any assignable object.
+	IssueTimelineItemsItemTypeClosedEvent                IssueTimelineItemsItemType = "CLOSED_EVENT"                   // Represents a 'closed' event on any `Closable`.
+	IssueTimelineItemsItemTypeCommentDeletedEvent        IssueTimelineItemsItemType = "COMMENT_DELETED_EVENT"          // Represents a 'comment_deleted' event on a given issue or pull request.
+	IssueTimelineItemsItemTypeConvertedNoteToIssueEvent  IssueTimelineItemsItemType = "CONVERTED_NOTE_TO_ISSUE_EVENT"  // Represents a 'converted_note_to_issue' event on a given issue or pull request.
+	IssueTimelineItemsItemTypeDemilestonedEvent          IssueTimelineItemsItemType = "DEMILESTONED_EVENT"             // Represents a 'demilestoned' event on a given issue or pull request.
+	IssueTimelineItemsItemTypeLabeledEvent               IssueTimelineItemsItemType = "LABELED_EVENT"                  // Represents a 'labeled' event on a given issue or pull request.
+	IssueTimelineItemsItemTypeLockedEvent                IssueTimelineItemsItemType = "LOCKED_EVENT"                   // Represents a 'locked' event on a given issue or pull request.
+	IssueTimelineItemsItemTypeMentionedEvent             IssueTimelineItemsItemType = "MENTIONED_EVENT"                // Represents a 'mentioned' event on a given issue or pull request.
+	IssueTimelineItemsItemTypeMilestonedEvent            IssueTimelineItemsItemType = "MILESTONED_EVENT"               // Represents a 'milestoned' event on a given issue or pull request.
+	IssueTimelineItemsItemTypeMovedColumnsInProjectEvent IssueTimelineItemsItemType = "MOVED_COLUMNS_IN_PROJECT_EVENT" // Represents a 'moved_columns_in_project' event on a given issue or pull request.
+	IssueTimelineItemsItemTypePinnedEvent                IssueTimelineItemsItemType = "PINNED_EVENT"                   // Represents a 'pinned' event on a given issue or pull request.
+	IssueTimelineItemsItemTypeReferencedEvent            IssueTimelineItemsItemType = "REFERENCED_EVENT"               // Represents a 'referenced' event on a given `ReferencedSubject`.
+	IssueTimelineItemsItemTypeRemovedFromProjectEvent    IssueTimelineItemsItemType = "REMOVED_FROM_PROJECT_EVENT"     // Represents a 'removed_from_project' event on a given issue or pull request.
+	IssueTimelineItemsItemTypeRenamedTitleEvent          IssueTimelineItemsItemType = "RENAMED_TITLE_EVENT"            // Represents a 'renamed' event on a given issue or pull request.
+	IssueTimelineItemsItemTypeReopenedEvent              IssueTimelineItemsItemType = "REOPENED_EVENT"                 // Represents a 'reopened' event on any `Closable`.
+	IssueTimelineItemsItemTypeSubscribedEvent            IssueTimelineItemsItemType = "SUBSCRIBED_EVENT"               // Represents a 'subscribed' event on a given `Subscribable`.
+	IssueTimelineItemsItemTypeTransferredEvent           IssueTimelineItemsItemType = "TRANSFERRED_EVENT"              // Represents a 'transferred' event on a given issue or pull request.
+	IssueTimelineItemsItemTypeUnassignedEvent            IssueTimelineItemsItemType = "UNASSIGNED_EVENT"               // Represents an 'unassigned' event on any assignable object.
+	IssueTimelineItemsItemTypeUnlabeledEvent             IssueTimelineItemsItemType = "UNLABELED_EVENT"                // Represents an 'unlabeled' event on a given issue or pull request.
+	IssueTimelineItemsItemTypeUnlockedEvent              IssueTimelineItemsItemType = "UNLOCKED_EVENT"                 // Represents an 'unlocked' event on a given issue or pull request.
+	IssueTimelineItemsItemTypeUserBlockedEvent           IssueTimelineItemsItemType = "USER_BLOCKED_EVENT"             // Represents a 'user_blocked' event on a given user.
+	IssueTimelineItemsItemTypeUnpinnedEvent              IssueTimelineItemsItemType = "UNPINNED_EVENT"                 // Represents an 'unpinned' event on a given issue or pull request.
+	IssueTimelineItemsItemTypeUnsubscribedEvent          IssueTimelineItemsItemType = "UNSUBSCRIBED_EVENT"             // Represents an 'unsubscribed' event on a given `Subscribable`.
+)
+
 // LanguageOrderField represents properties by which language connections can be ordered.
 type LanguageOrderField string
 
@@ -225,6 +300,34 @@ const (
 	OrganizationInvitationTypeEmail OrganizationInvitationType = "EMAIL" // The invitation was to an email address.
 )
 
+// OrganizationMemberRole represents the possible roles within an organization for its members.
+type OrganizationMemberRole string
+
+// The possible roles within an organization for its members.
+const (
+	OrganizationMemberRoleMember OrganizationMemberRole = "MEMBER" // The user is a member of the organization.
+	OrganizationMemberRoleAdmin  OrganizationMemberRole = "ADMIN"  // The user is an administrator of the organization.
+)
+
+// PinnableItemType represents represents items that can be pinned to a profile page or dashboard.
+type PinnableItemType string
+
+// Represents items that can be pinned to a profile page or dashboard.
+const (
+	PinnableItemTypeRepository PinnableItemType = "REPOSITORY" // A repository.
+	PinnableItemTypeGist       PinnableItemType = "GIST"       // A gist.
+	PinnableItemTypeIssue      PinnableItemType = "ISSUE"      // An issue.
+)
+
+// ProjectCardArchivedState represents the possible archived states of a project card.
+type ProjectCardArchivedState string
+
+// The possible archived states of a project card.
+const (
+	ProjectCardArchivedStateArchived    ProjectCardArchivedState = "ARCHIVED"     // A project card that is archived.
+	ProjectCardArchivedStateNotArchived ProjectCardArchivedState = "NOT_ARCHIVED" // A project card that is not archived.
+)
+
 // ProjectCardState represents various content states of a ProjectCard.
 type ProjectCardState string
 
@@ -235,6 +338,16 @@ const (
 	ProjectCardStateRedacted    ProjectCardState = "REDACTED"     // The card is redacted.
 )
 
+// ProjectColumnPurpose represents the semantic purpose of the column - todo, in progress, or done.
+type ProjectColumnPurpose string
+
+// The semantic purpose of the column - todo, in progress, or done.
+const (
+	ProjectColumnPurposeTodo       ProjectColumnPurpose = "TODO"        // The column contains cards still to be worked on.
+	ProjectColumnPurposeInProgress ProjectColumnPurpose = "IN_PROGRESS" // The column contains cards which are currently being worked on.
+	ProjectColumnPurposeDone       ProjectColumnPurpose = "DONE"        // The column contains cards which are complete.
+)
+
 // ProjectOrderField represents properties by which project connections can be ordered.
 type ProjectOrderField string
 
@@ -254,6 +367,15 @@ const (
 	ProjectStateClosed ProjectState = "CLOSED" // The project is closed.
 )
 
+// PullRequestOrderField represents properties by which pull_requests connections can be ordered.
+type PullRequestOrderField string
+
+// Properties by which pull_requests connections can be ordered.
+const (
+	PullRequestOrderFieldCreatedAt PullRequestOrderField = "CREATED_AT" // Order pull_requests by creation time.
+	PullRequestOrderFieldUpdatedAt PullRequestOrderField = "UPDATED_AT" // Order pull_requests by update time.
+)
+
 // PullRequestPubSubTopic represents the possible PubSub channels for a pull request.
 type PullRequestPubSubTopic string
 
@@ -263,6 +385,16 @@ const (
 	PullRequestPubSubTopicMarkasread PullRequestPubSubTopic = "MARKASREAD" // The channel ID for marking an pull request as read.
 	PullRequestPubSubTopicHeadRef    PullRequestPubSubTopic = "HEAD_REF"   // The channel ID for observing head ref updates.
 	PullRequestPubSubTopicTimeline   PullRequestPubSubTopic = "TIMELINE"   // The channel ID for updating items on the pull request timeline.
+	PullRequestPubSubTopicState      PullRequestPubSubTopic = "STATE"      // The channel ID for observing pull request state updates.
+)
+
+// PullRequestReviewCommentState represents the possible states of a pull request review comment.
+type PullRequestReviewCommentState string
+
+// The possible states of a pull request review comment.
+const (
+	PullRequestReviewCommentStatePending   PullRequestReviewCommentState = "PENDING"   // A comment that is part of a pending review.
+	PullRequestReviewCommentStateSubmitted PullRequestReviewCommentState = "SUBMITTED" // A comment that is part of a submitted review.
 )
 
 // PullRequestReviewEvent represents the possible events to perform on a pull request review.
@@ -298,6 +430,55 @@ const (
 	PullRequestStateMerged PullRequestState = "MERGED" // A pull request that has been closed by being merged.
 )
 
+// PullRequestTimelineItemsItemType represents the possible item types found in a timeline.
+type PullRequestTimelineItemsItemType string
+
+// The possible item types found in a timeline.
+const (
+	PullRequestTimelineItemsItemTypePullRequestCommit                 PullRequestTimelineItemsItemType = "PULL_REQUEST_COMMIT"                  // Represents a Git commit part of a pull request.
+	PullRequestTimelineItemsItemTypePullRequestCommitCommentThread    PullRequestTimelineItemsItemType = "PULL_REQUEST_COMMIT_COMMENT_THREAD"   // Represents a commit comment thread part of a pull request.
+	PullRequestTimelineItemsItemTypePullRequestReview                 PullRequestTimelineItemsItemType = "PULL_REQUEST_REVIEW"                  // A review object for a given pull request.
+	PullRequestTimelineItemsItemTypePullRequestReviewThread           PullRequestTimelineItemsItemType = "PULL_REQUEST_REVIEW_THREAD"           // A threaded list of comments for a given pull request.
+	PullRequestTimelineItemsItemTypePullRequestRevisionMarker         PullRequestTimelineItemsItemType = "PULL_REQUEST_REVISION_MARKER"         // Represents the latest point in the pull request timeline for which the viewer has seen the pull request's commits.
+	PullRequestTimelineItemsItemTypeBaseRefChangedEvent               PullRequestTimelineItemsItemType = "BASE_REF_CHANGED_EVENT"               // Represents a 'base_ref_changed' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeBaseRefForcePushedEvent           PullRequestTimelineItemsItemType = "BASE_REF_FORCE_PUSHED_EVENT"          // Represents a 'base_ref_force_pushed' event on a given pull request.
+	PullRequestTimelineItemsItemTypeDeployedEvent                     PullRequestTimelineItemsItemType = "DEPLOYED_EVENT"                       // Represents a 'deployed' event on a given pull request.
+	PullRequestTimelineItemsItemTypeDeploymentEnvironmentChangedEvent PullRequestTimelineItemsItemType = "DEPLOYMENT_ENVIRONMENT_CHANGED_EVENT" // Represents a 'deployment_environment_changed' event on a given pull request.
+	PullRequestTimelineItemsItemTypeHeadRefDeletedEvent               PullRequestTimelineItemsItemType = "HEAD_REF_DELETED_EVENT"               // Represents a 'head_ref_deleted' event on a given pull request.
+	PullRequestTimelineItemsItemTypeHeadRefForcePushedEvent           PullRequestTimelineItemsItemType = "HEAD_REF_FORCE_PUSHED_EVENT"          // Represents a 'head_ref_force_pushed' event on a given pull request.
+	PullRequestTimelineItemsItemTypeHeadRefRestoredEvent              PullRequestTimelineItemsItemType = "HEAD_REF_RESTORED_EVENT"              // Represents a 'head_ref_restored' event on a given pull request.
+	PullRequestTimelineItemsItemTypeMergedEvent                       PullRequestTimelineItemsItemType = "MERGED_EVENT"                         // Represents a 'merged' event on a given pull request.
+	PullRequestTimelineItemsItemTypeReviewDismissedEvent              PullRequestTimelineItemsItemType = "REVIEW_DISMISSED_EVENT"               // Represents a 'review_dismissed' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeReviewRequestedEvent              PullRequestTimelineItemsItemType = "REVIEW_REQUESTED_EVENT"               // Represents an 'review_requested' event on a given pull request.
+	PullRequestTimelineItemsItemTypeReviewRequestRemovedEvent         PullRequestTimelineItemsItemType = "REVIEW_REQUEST_REMOVED_EVENT"         // Represents an 'review_request_removed' event on a given pull request.
+	PullRequestTimelineItemsItemTypeIssueComment                      PullRequestTimelineItemsItemType = "ISSUE_COMMENT"                        // Represents a comment on an Issue.
+	PullRequestTimelineItemsItemTypeCrossReferencedEvent              PullRequestTimelineItemsItemType = "CROSS_REFERENCED_EVENT"               // Represents a mention made by one issue or pull request to another.
+	PullRequestTimelineItemsItemTypeAddedToProjectEvent               PullRequestTimelineItemsItemType = "ADDED_TO_PROJECT_EVENT"               // Represents a 'added_to_project' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeAssignedEvent                     PullRequestTimelineItemsItemType = "ASSIGNED_EVENT"                       // Represents an 'assigned' event on any assignable object.
+	PullRequestTimelineItemsItemTypeClosedEvent                       PullRequestTimelineItemsItemType = "CLOSED_EVENT"                         // Represents a 'closed' event on any `Closable`.
+	PullRequestTimelineItemsItemTypeCommentDeletedEvent               PullRequestTimelineItemsItemType = "COMMENT_DELETED_EVENT"                // Represents a 'comment_deleted' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeConvertedNoteToIssueEvent         PullRequestTimelineItemsItemType = "CONVERTED_NOTE_TO_ISSUE_EVENT"        // Represents a 'converted_note_to_issue' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeDemilestonedEvent                 PullRequestTimelineItemsItemType = "DEMILESTONED_EVENT"                   // Represents a 'demilestoned' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeLabeledEvent                      PullRequestTimelineItemsItemType = "LABELED_EVENT"                        // Represents a 'labeled' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeLockedEvent                       PullRequestTimelineItemsItemType = "LOCKED_EVENT"                         // Represents a 'locked' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeMentionedEvent                    PullRequestTimelineItemsItemType = "MENTIONED_EVENT"                      // Represents a 'mentioned' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeMilestonedEvent                   PullRequestTimelineItemsItemType = "MILESTONED_EVENT"                     // Represents a 'milestoned' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeMovedColumnsInProjectEvent        PullRequestTimelineItemsItemType = "MOVED_COLUMNS_IN_PROJECT_EVENT"       // Represents a 'moved_columns_in_project' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypePinnedEvent                       PullRequestTimelineItemsItemType = "PINNED_EVENT"                         // Represents a 'pinned' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeReferencedEvent                   PullRequestTimelineItemsItemType = "REFERENCED_EVENT"                     // Represents a 'referenced' event on a given `ReferencedSubject`.
+	PullRequestTimelineItemsItemTypeRemovedFromProjectEvent           PullRequestTimelineItemsItemType = "REMOVED_FROM_PROJECT_EVENT"           // Represents a 'removed_from_project' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeRenamedTitleEvent                 PullRequestTimelineItemsItemType = "RENAMED_TITLE_EVENT"                  // Represents a 'renamed' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeReopenedEvent                     PullRequestTimelineItemsItemType = "REOPENED_EVENT"                       // Represents a 'reopened' event on any `Closable`.
+	PullRequestTimelineItemsItemTypeSubscribedEvent                   PullRequestTimelineItemsItemType = "SUBSCRIBED_EVENT"                     // Represents a 'subscribed' event on a given `Subscribable`.
+	PullRequestTimelineItemsItemTypeTransferredEvent                  PullRequestTimelineItemsItemType = "TRANSFERRED_EVENT"                    // Represents a 'transferred' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeUnassignedEvent                   PullRequestTimelineItemsItemType = "UNASSIGNED_EVENT"                     // Represents an 'unassigned' event on any assignable object.
+	PullRequestTimelineItemsItemTypeUnlabeledEvent                    PullRequestTimelineItemsItemType = "UNLABELED_EVENT"                      // Represents an 'unlabeled' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeUnlockedEvent                     PullRequestTimelineItemsItemType = "UNLOCKED_EVENT"                       // Represents an 'unlocked' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeUserBlockedEvent                  PullRequestTimelineItemsItemType = "USER_BLOCKED_EVENT"                   // Represents a 'user_blocked' event on a given user.
+	PullRequestTimelineItemsItemTypeUnpinnedEvent                     PullRequestTimelineItemsItemType = "UNPINNED_EVENT"                       // Represents an 'unpinned' event on a given issue or pull request.
+	PullRequestTimelineItemsItemTypeUnsubscribedEvent                 PullRequestTimelineItemsItemType = "UNSUBSCRIBED_EVENT"                   // Represents an 'unsubscribed' event on a given `Subscribable`.
+)
+
 // ReactionContent represents emojis that can be attached to Issues, Pull Requests and Comments.
 type ReactionContent string
 
@@ -309,6 +490,8 @@ const (
 	ReactionContentHooray     ReactionContent = "HOORAY"      // Represents the 🎉 emoji.
 	ReactionContentConfused   ReactionContent = "CONFUSED"    // Represents the 😕 emoji.
 	ReactionContentHeart      ReactionContent = "HEART"       // Represents the ❤️ emoji.
+	ReactionContentRocket     ReactionContent = "ROCKET"      // Represents the 🚀 emoji.
+	ReactionContentEyes       ReactionContent = "EYES"        // Represents the 👀 emoji.
 )
 
 // ReactionOrderField represents a list of fields that reactions can be ordered by.
@@ -337,6 +520,18 @@ const (
 	ReleaseOrderFieldName      ReleaseOrderField = "NAME"       // Order releases alphabetically by name.
 )
 
+// ReportedContentClassifiers represents the reasons a piece of content can be reported or minimized.
+type ReportedContentClassifiers string
+
+// The reasons a piece of content can be reported or minimized.
+const (
+	ReportedContentClassifiersSpam     ReportedContentClassifiers = "SPAM"      // A spammy piece of content.
+	ReportedContentClassifiersAbuse    ReportedContentClassifiers = "ABUSE"     // An abusive or harassing piece of content.
+	ReportedContentClassifiersOffTopic ReportedContentClassifiers = "OFF_TOPIC" // An irrelevant piece of content.
+	ReportedContentClassifiersOutdated ReportedContentClassifiers = "OUTDATED"  // An outdated piece of content.
+	ReportedContentClassifiersResolved ReportedContentClassifiers = "RESOLVED"  // The content has been resolved.
+)
+
 // RepositoryAffiliation represents the affiliation of a user to a repository.
 type RepositoryAffiliation string
 
@@ -396,9 +591,11 @@ type RepositoryPermission string
 
 // The access level to a repository.
 const (
-	RepositoryPermissionAdmin RepositoryPermission = "ADMIN" // Can read, clone, push, and add collaborators.
-	RepositoryPermissionWrite RepositoryPermission = "WRITE" // Can read, clone and push.
-	RepositoryPermissionRead  RepositoryPermission = "READ"  // Can read and clone.
+	RepositoryPermissionAdmin    RepositoryPermission = "ADMIN"    // Can read, clone, and push to this repository. Can also manage issues, pull requests, and repository settings, including adding collaborators.
+	RepositoryPermissionMaintain RepositoryPermission = "MAINTAIN" // Can read, clone, and push to this repository. They can also manage issues, pull requests, and some repository settings.
+	RepositoryPermissionWrite    RepositoryPermission = "WRITE"    // Can read, clone, and push to this repository. Can also manage issues and pull requests.
+	RepositoryPermissionTriage   RepositoryPermission = "TRIAGE"   // Can read and clone this repository. Can also manage issues and pull requests.
+	RepositoryPermissionRead     RepositoryPermission = "READ"     // Can read and clone this repository. Can also open and comment on issues and pull requests.
 )
 
 // RepositoryPrivacy represents the privacy of a repository.
@@ -420,6 +617,55 @@ const (
 	SearchTypeUser       SearchType = "USER"       // Returns results matching users and organizations on GitHub.
 )
 
+// SecurityAdvisoryEcosystem represents the possible ecosystems of a security vulnerability's package.
+type SecurityAdvisoryEcosystem string
+
+// The possible ecosystems of a security vulnerability's package.
+const (
+	SecurityAdvisoryEcosystemRubygems SecurityAdvisoryEcosystem = "RUBYGEMS" // Ruby gems hosted at RubyGems.org.
+	SecurityAdvisoryEcosystemNpm      SecurityAdvisoryEcosystem = "NPM"      // JavaScript packages hosted at npmjs.com.
+	SecurityAdvisoryEcosystemPip      SecurityAdvisoryEcosystem = "PIP"      // Python packages hosted at PyPI.org.
+	SecurityAdvisoryEcosystemMaven    SecurityAdvisoryEcosystem = "MAVEN"    // Java artifacts hosted at the Maven central repository.
+	SecurityAdvisoryEcosystemNuget    SecurityAdvisoryEcosystem = "NUGET"    // .NET packages hosted at the NuGet Gallery.
+)
+
+// SecurityAdvisoryIdentifierType represents identifier formats available for advisories.
+type SecurityAdvisoryIdentifierType string
+
+// Identifier formats available for advisories.
+const (
+	SecurityAdvisoryIdentifierTypeCve  SecurityAdvisoryIdentifierType = "CVE"  // Common Vulnerabilities and Exposures Identifier.
+	SecurityAdvisoryIdentifierTypeGhsa SecurityAdvisoryIdentifierType = "GHSA" // GitHub Security Advisory ID.
+)
+
+// SecurityAdvisoryOrderField represents properties by which security advisory connections can be ordered.
+type SecurityAdvisoryOrderField string
+
+// Properties by which security advisory connections can be ordered.
+const (
+	SecurityAdvisoryOrderFieldPublishedAt SecurityAdvisoryOrderField = "PUBLISHED_AT" // Order advisories by publication time.
+	SecurityAdvisoryOrderFieldUpdatedAt   SecurityAdvisoryOrderField = "UPDATED_AT"   // Order advisories by update time.
+)
+
+// SecurityAdvisorySeverity represents severity of the vulnerability.
+type SecurityAdvisorySeverity string
+
+// Severity of the vulnerability.
+const (
+	SecurityAdvisorySeverityLow      SecurityAdvisorySeverity = "LOW"      // Low.
+	SecurityAdvisorySeverityModerate SecurityAdvisorySeverity = "MODERATE" // Moderate.
+	SecurityAdvisorySeverityHigh     SecurityAdvisorySeverity = "HIGH"     // High.
+	SecurityAdvisorySeverityCritical SecurityAdvisorySeverity = "CRITICAL" // Critical.
+)
+
+// SecurityVulnerabilityOrderField represents properties by which security vulnerability connections can be ordered.
+type SecurityVulnerabilityOrderField string
+
+// Properties by which security vulnerability connections can be ordered.
+const (
+	SecurityVulnerabilityOrderFieldUpdatedAt SecurityVulnerabilityOrderField = "UPDATED_AT" // Order vulnerability by update time.
+)
+
 // StarOrderField represents properties by which star connections can be ordered.
 type StarOrderField string
 
@@ -445,7 +691,7 @@ type SubscriptionState string
 
 // The possible states of a subscription.
 const (
-	SubscriptionStateUnsubscribed SubscriptionState = "UNSUBSCRIBED" // The User is only notified when particpating or @mentioned.
+	SubscriptionStateUnsubscribed SubscriptionState = "UNSUBSCRIBED" // The User is only notified when participating or @mentioned.
 	SubscriptionStateSubscribed   SubscriptionState = "SUBSCRIBED"   // The User is notified of all conversations.
 	SubscriptionStateIgnored      SubscriptionState = "IGNORED"      // The User is never notified.
 )
@@ -527,3 +773,23 @@ const (
 	TopicSuggestionDeclineReasonPersonalPreference TopicSuggestionDeclineReason = "PERSONAL_PREFERENCE" // The viewer does not like the suggested topic.
 	TopicSuggestionDeclineReasonTooGeneral         TopicSuggestionDeclineReason = "TOO_GENERAL"         // The suggested topic is too general for the repository.
 )
+
+// UserBlockDuration represents the possible durations that a user can be blocked for.
+type UserBlockDuration string
+
+// The possible durations that a user can be blocked for.
+const (
+	UserBlockDurationOneDay    UserBlockDuration = "ONE_DAY"    // The user was blocked for 1 day.
+	UserBlockDurationThreeDays UserBlockDuration = "THREE_DAYS" // The user was blocked for 3 days.
+	UserBlockDurationOneWeek   UserBlockDuration = "ONE_WEEK"   // The user was blocked for 7 days.
+	UserBlockDurationOneMonth  UserBlockDuration = "ONE_MONTH"  // The user was blocked for 30 days.
+	UserBlockDurationPermanent UserBlockDuration = "PERMANENT"  // The user was blocked permanently.
+)
+
+// UserStatusOrderField represents properties by which user status connections can be ordered.
+type UserStatusOrderField string
+
+// Properties by which user status connections can be ordered.
+const (
+	UserStatusOrderFieldUpdatedAt UserStatusOrderField = "UPDATED_AT" // Order user statuses by when they were updated.
+)

vendor/github.com/shurcooL/githubv4/gen.go 🔗

@@ -86,12 +86,12 @@ package githubv4
 
 
 {{- define "enum" -}}
-// {{.name}} {{.description | endSentence}}
+// {{.name}} {{.description | clean | endSentence}}
 type {{.name}} string
 
-// {{.description | fullSentence}}
+// {{.description | clean | fullSentence}}
 const ({{range .enumValues}}
-	{{$.name}}{{.name | enumIdentifier}} {{$.name}} = {{.name | quote}} // {{.description | fullSentence}}{{end}}
+	{{$.name}}{{.name | enumIdentifier}} {{$.name}} = {{.name | quote}} // {{.description | clean | fullSentence}}{{end}}
 )
 {{- end -}}
 `),
@@ -110,12 +110,12 @@ type Input interface{}
 
 
 {{- define "inputObject" -}}
-// {{.name}} {{.description | endSentence}}
+// {{.name}} {{.description | clean | endSentence}}
 type {{.name}} struct {{"{"}}{{range .inputFields}}{{if eq .type.kind "NON_NULL"}}
-	// {{.description | fullSentence}} (Required.)
+	// {{.description | clean | fullSentence}} (Required.)
 	{{.name | identifier}} {{.type | type}} ` + "`" + `json:"{{.name}}"` + "`" + `{{end}}{{end}}
 {{range .inputFields}}{{if ne .type.kind "NON_NULL"}}
-	// {{.description | fullSentence}} (Optional.)
+	// {{.description | clean | fullSentence}} (Optional.)
 	{{.name | identifier}} {{.type | type}} ` + "`" + `json:"{{.name}},omitempty"` + "`" + `{{end}}{{end}}
 }
 {{- end -}}
@@ -167,6 +167,7 @@ func t(text string) *template.Template {
 		"identifier":     func(name string) string { return ident.ParseLowerCamelCase(name).ToMixedCaps() },
 		"enumIdentifier": func(name string) string { return ident.ParseScreamingSnakeCase(name).ToMixedCaps() },
 		"type":           typeString,
+		"clean":          func(s string) string { return strings.Join(strings.Fields(s), " ") },
 		"endSentence": func(s string) string {
 			s = strings.ToLower(s[0:1]) + s[1:]
 			switch {

vendor/github.com/shurcooL/githubv4/input.go 🔗

@@ -4,7 +4,7 @@ package githubv4
 
 // Input represents one of the Input structs:
 //
-// AcceptTopicSuggestionInput, AddCommentInput, AddProjectCardInput, AddProjectColumnInput, AddPullRequestReviewCommentInput, AddPullRequestReviewInput, AddReactionInput, AddStarInput, CommitAuthor, CreateProjectInput, DeclineTopicSuggestionInput, DeleteProjectCardInput, DeleteProjectColumnInput, DeleteProjectInput, DeletePullRequestReviewInput, DismissPullRequestReviewInput, DraftPullRequestReviewComment, GistOrder, IssueOrder, LanguageOrder, LockLockableInput, MilestoneOrder, MoveProjectCardInput, MoveProjectColumnInput, ProjectOrder, ReactionOrder, RefOrder, ReleaseOrder, RemoveOutsideCollaboratorInput, RemoveReactionInput, RemoveStarInput, RepositoryOrder, RequestReviewsInput, StarOrder, SubmitPullRequestReviewInput, TeamMemberOrder, TeamOrder, TeamRepositoryOrder, UpdateProjectCardInput, UpdateProjectColumnInput, UpdateProjectInput, UpdatePullRequestReviewCommentInput, UpdatePullRequestReviewInput, UpdateSubscriptionInput, UpdateTopicsInput.