export.go

  1package github
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"io/ioutil"
  9	"net/http"
 10	"time"
 11
 12	"github.com/pkg/errors"
 13	"github.com/shurcooL/githubv4"
 14
 15	"github.com/MichaelMure/git-bug/bridge/core"
 16	"github.com/MichaelMure/git-bug/bug"
 17	"github.com/MichaelMure/git-bug/cache"
 18	"github.com/MichaelMure/git-bug/util/git"
 19)
 20
 21var (
 22	ErrMissingIdentityToken = errors.New("missing identity token")
 23)
 24
 25// githubExporter implement the Exporter interface
 26type githubExporter struct {
 27	conf core.Configuration
 28
 29	// export only bugs tagged with one of these origins
 30	onlyOrigins []string
 31
 32	// cache identities clients
 33	identityClient map[string]*githubv4.Client
 34
 35	// map identities with their tokens
 36	identityToken map[string]string
 37
 38	// github repository ID
 39	repositoryID string
 40
 41	// cache identifiers used to speed up exporting operations
 42	// cleared for each bug
 43	cachedOperationIDs map[string]string
 44
 45	// cache labels used to speed up exporting labels events
 46	cachedLabels map[string]string
 47}
 48
 49// Init .
 50func (ge *githubExporter) Init(conf core.Configuration) error {
 51	ge.conf = conf
 52	//TODO: initialize with multiple tokens
 53	ge.identityToken = make(map[string]string)
 54	ge.identityClient = make(map[string]*githubv4.Client)
 55	ge.cachedOperationIDs = make(map[string]string)
 56	ge.cachedLabels = make(map[string]string)
 57	return nil
 58}
 59
 60// allowOrigin verify that origin is allowed to get exported.
 61// if the exporter was initialized with no specified origins, it will return
 62// true for all origins
 63func (ge *githubExporter) allowOrigin(origin string) bool {
 64	if len(ge.onlyOrigins) == 0 {
 65		return true
 66	}
 67
 68	for _, o := range ge.onlyOrigins {
 69		if origin == o {
 70			return true
 71		}
 72	}
 73
 74	return false
 75}
 76
 77// getIdentityClient return a githubv4 API client configured with the access token of the given identity.
 78// if no client were found it will initialize it from the known tokens map and cache it for next use
 79func (ge *githubExporter) getIdentityClient(id string) (*githubv4.Client, error) {
 80	client, ok := ge.identityClient[id]
 81	if ok {
 82		return client, nil
 83	}
 84
 85	// get token
 86	token, ok := ge.identityToken[id]
 87	if !ok {
 88		return nil, ErrMissingIdentityToken
 89	}
 90
 91	// create client
 92	client = buildClient(token)
 93	// cache client
 94	ge.identityClient[id] = client
 95
 96	return client, nil
 97}
 98
 99// ExportAll export all event made by the current user to Github
100func (ge *githubExporter) ExportAll(repo *cache.RepoCache, since time.Time) (<-chan core.ExportResult, error) {
101	out := make(chan core.ExportResult)
102
103	user, err := repo.GetUserIdentity()
104	if err != nil {
105		return nil, err
106	}
107
108	ge.identityToken[user.Id()] = ge.conf[keyToken]
109
110	// get repository node id
111	ge.repositoryID, err = getRepositoryNodeID(
112		ge.conf[keyOwner],
113		ge.conf[keyProject],
114		ge.conf[keyToken],
115	)
116
117	if err != nil {
118		return nil, err
119	}
120
121	go func() {
122		defer close(out)
123
124		var allIdentitiesIds []string
125		for id := range ge.identityToken {
126			allIdentitiesIds = append(allIdentitiesIds, id)
127		}
128
129		allBugsIds := repo.AllBugsIds()
130
131		for _, id := range allBugsIds {
132			b, err := repo.ResolveBug(id)
133			if err != nil {
134				out <- core.NewExportError(err, id)
135				return
136			}
137
138			snapshot := b.Snapshot()
139
140			// ignore issues created before since date
141			// TODO: compare the Lamport time instead of using the unix time
142			if snapshot.CreatedAt.Before(since) {
143				out <- core.NewExportNothing(b.Id(), "bug created before the since date")
144				continue
145			}
146
147			if snapshot.HasAnyActor(allIdentitiesIds...) {
148				// try to export the bug and it associated events
149				ge.exportBug(b, since, out)
150			} else {
151				out <- core.NewExportNothing(id, "not an actor")
152			}
153		}
154	}()
155
156	return out, nil
157}
158
159// exportBug publish bugs and related events
160func (ge *githubExporter) exportBug(b *cache.BugCache, since time.Time, out chan<- core.ExportResult) {
161	snapshot := b.Snapshot()
162
163	var bugGithubID string
164	var bugGithubURL string
165	var bugCreationHash string
166
167	// Special case:
168	// if a user try to export a bug that is not already exported to Github (or imported
169	// from Github) and we do not have the token of the bug author, there is nothing we can do.
170
171	// first operation is always createOp
172	createOp := snapshot.Operations[0].(*bug.CreateOperation)
173	author := snapshot.Author
174
175	// skip bug if origin is not allowed
176	origin, ok := createOp.GetMetadata(keyOrigin)
177	if ok && !ge.allowOrigin(origin) {
178		out <- core.NewExportNothing(b.Id(), fmt.Sprintf("issue tagged with origin: %s", origin))
179		return
180	}
181
182	// get github bug ID
183	githubID, ok := createOp.GetMetadata(keyGithubId)
184	if ok {
185		githubURL, ok := createOp.GetMetadata(keyGithubUrl)
186		if !ok {
187			// if we find github ID, github URL must be found too
188			err := fmt.Errorf("expected to find github issue URL")
189			out <- core.NewExportError(err, b.Id())
190		}
191
192		out <- core.NewExportNothing(b.Id(), "bug already exported")
193		// will be used to mark operation related to a bug as exported
194		bugGithubID = githubID
195		bugGithubURL = githubURL
196
197	} else {
198		// check that we have a token for operation author
199		client, err := ge.getIdentityClient(author.Id())
200		if err != nil {
201			// if bug is still not exported and we do not have the author stop the execution
202			out <- core.NewExportNothing(b.Id(), fmt.Sprintf("missing author token"))
203			return
204		}
205
206		// create bug
207		id, url, err := createGithubIssue(client, ge.repositoryID, createOp.Title, createOp.Message)
208		if err != nil {
209			err := errors.Wrap(err, "exporting github issue")
210			out <- core.NewExportError(err, b.Id())
211			return
212		}
213
214		out <- core.NewExportBug(b.Id())
215
216		hash, err := createOp.Hash()
217		if err != nil {
218			err := errors.Wrap(err, "comment hash")
219			out <- core.NewExportError(err, b.Id())
220			return
221		}
222
223		// mark bug creation operation as exported
224		if err := markOperationAsExported(b, hash, id, url); err != nil {
225			err := errors.Wrap(err, "marking operation as exported")
226			out <- core.NewExportError(err, b.Id())
227			return
228		}
229
230		// commit operation to avoid creating multiple issues with multiple pushes
231		if err := b.CommitAsNeeded(); err != nil {
232			err := errors.Wrap(err, "bug commit")
233			out <- core.NewExportError(err, b.Id())
234			return
235		}
236
237		// cache bug github ID and URL
238		bugGithubID = id
239		bugGithubURL = url
240	}
241
242	// get createOp hash
243	hash, err := createOp.Hash()
244	if err != nil {
245		out <- core.NewExportError(err, b.Id())
246		return
247	}
248
249	bugCreationHash = hash.String()
250
251	// cache operation github id
252	ge.cachedOperationIDs[bugCreationHash] = bugGithubID
253
254	for _, op := range snapshot.Operations[1:] {
255		// ignore SetMetadata operations
256		if _, ok := op.(*bug.SetMetadataOperation); ok {
257			continue
258		}
259
260		// get operation hash
261		hash, err := op.Hash()
262		if err != nil {
263			err := errors.Wrap(err, "operation hash")
264			out <- core.NewExportError(err, b.Id())
265			return
266		}
267
268		// ignore operations already existing in github (due to import or export)
269		// cache the ID of already exported or imported issues and events from Github
270		if id, ok := op.GetMetadata(keyGithubId); ok {
271			ge.cachedOperationIDs[hash.String()] = id
272			out <- core.NewExportNothing(hash.String(), "already exported operation")
273			continue
274		}
275
276		opAuthor := op.GetAuthor()
277		client, err := ge.getIdentityClient(opAuthor.Id())
278		if err != nil {
279			out <- core.NewExportNothing(hash.String(), "missing operation author token")
280			continue
281		}
282
283		var id, url string
284		switch op.(type) {
285		case *bug.AddCommentOperation:
286			opr := op.(*bug.AddCommentOperation)
287
288			// send operation to github
289			id, url, err = addCommentGithubIssue(client, bugGithubID, opr.Message)
290			if err != nil {
291				err := errors.Wrap(err, "adding comment")
292				out <- core.NewExportError(err, b.Id())
293				return
294			}
295
296			out <- core.NewExportComment(hash.String())
297
298			// cache comment id
299			ge.cachedOperationIDs[hash.String()] = id
300
301		case *bug.EditCommentOperation:
302
303			opr := op.(*bug.EditCommentOperation)
304			targetHash := opr.Target.String()
305
306			// Since github doesn't consider the issue body as a comment
307			if targetHash == bugCreationHash {
308
309				// case bug creation operation: we need to edit the Github issue
310				if err := updateGithubIssueBody(client, bugGithubID, opr.Message); err != nil {
311					err := errors.Wrap(err, "editing issue")
312					out <- core.NewExportError(err, b.Id())
313					return
314				}
315
316				out <- core.NewExportCommentEdition(hash.String())
317
318				id = bugGithubID
319				url = bugGithubURL
320
321			} else {
322
323				// case comment edition operation: we need to edit the Github comment
324				commentID, ok := ge.cachedOperationIDs[targetHash]
325				if !ok {
326					panic("unexpected error: comment id not found")
327				}
328
329				eid, eurl, err := editCommentGithubIssue(client, commentID, opr.Message)
330				if err != nil {
331					err := errors.Wrap(err, "editing comment")
332					out <- core.NewExportError(err, b.Id())
333					return
334				}
335
336				out <- core.NewExportCommentEdition(hash.String())
337
338				// use comment id/url instead of issue id/url
339				id = eid
340				url = eurl
341			}
342
343		case *bug.SetStatusOperation:
344			opr := op.(*bug.SetStatusOperation)
345			if err := updateGithubIssueStatus(client, bugGithubID, opr.Status); err != nil {
346				err := errors.Wrap(err, "editing status")
347				out <- core.NewExportError(err, b.Id())
348				return
349			}
350
351			out <- core.NewExportStatusChange(hash.String())
352
353			id = bugGithubID
354			url = bugGithubURL
355
356		case *bug.SetTitleOperation:
357			opr := op.(*bug.SetTitleOperation)
358			if err := updateGithubIssueTitle(client, bugGithubID, opr.Title); err != nil {
359				err := errors.Wrap(err, "editing title")
360				out <- core.NewExportError(err, b.Id())
361				return
362			}
363
364			out <- core.NewExportTitleEdition(hash.String())
365
366			id = bugGithubID
367			url = bugGithubURL
368
369		case *bug.LabelChangeOperation:
370			opr := op.(*bug.LabelChangeOperation)
371			if err := ge.updateGithubIssueLabels(client, bugGithubID, opr.Added, opr.Removed); err != nil {
372				err := errors.Wrap(err, "updating labels")
373				out <- core.NewExportError(err, b.Id())
374				return
375			}
376
377			out <- core.NewExportLabelChange(hash.String())
378
379			id = bugGithubID
380			url = bugGithubURL
381
382		default:
383			panic("unhandled operation type case")
384		}
385
386		// mark operation as exported
387		if err := markOperationAsExported(b, hash, id, url); err != nil {
388			err := errors.Wrap(err, "marking operation as exported")
389			out <- core.NewExportError(err, b.Id())
390			return
391		}
392
393		// commit at each operation export to avoid exporting same events multiple times
394		if err := b.CommitAsNeeded(); err != nil {
395			err := errors.Wrap(err, "bug commit")
396			out <- core.NewExportError(err, b.Id())
397			return
398		}
399	}
400}
401
402// getRepositoryNodeID request github api v3 to get repository node id
403func getRepositoryNodeID(owner, project, token string) (string, error) {
404	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
405
406	client := &http.Client{
407		Timeout: defaultTimeout,
408	}
409
410	req, err := http.NewRequest("GET", url, nil)
411	if err != nil {
412		return "", err
413	}
414
415	// need the token for private repositories
416	req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
417
418	resp, err := client.Do(req)
419	if err != nil {
420		return "", err
421	}
422
423	if resp.StatusCode != http.StatusOK {
424		return "", fmt.Errorf("HTTP error %v retrieving repository node id", resp.StatusCode)
425	}
426
427	aux := struct {
428		NodeID string `json:"node_id"`
429	}{}
430
431	data, _ := ioutil.ReadAll(resp.Body)
432	err = resp.Body.Close()
433	if err != nil {
434		return "", err
435	}
436
437	err = json.Unmarshal(data, &aux)
438	if err != nil {
439		return "", err
440	}
441
442	return aux.NodeID, nil
443}
444
445func markOperationAsExported(b *cache.BugCache, target git.Hash, githubID, githubURL string) error {
446	_, err := b.SetMetadata(
447		target,
448		map[string]string{
449			keyGithubId:  githubID,
450			keyGithubUrl: githubURL,
451		},
452	)
453
454	return err
455}
456
457// get label from github
458func (ge *githubExporter) getGithubLabelID(gc *githubv4.Client, label string) (string, error) {
459	q := &labelQuery{}
460	variables := map[string]interface{}{
461		"label": githubv4.String(label),
462		"owner": githubv4.String(ge.conf[keyOwner]),
463		"name":  githubv4.String(ge.conf[keyProject]),
464	}
465
466	parentCtx := context.Background()
467	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
468	defer cancel()
469
470	if err := gc.Query(ctx, q, variables); err != nil {
471		return "", err
472	}
473
474	// if label id is empty, it means there is no such label in this Github repository
475	if q.Repository.Label.ID == "" {
476		return "", fmt.Errorf("label not found")
477	}
478
479	return q.Repository.Label.ID, nil
480}
481
482// create a new label and return it github id
483// NOTE: since createLabel mutation is still in preview mode we use github api v3 to create labels
484// see https://developer.github.com/v4/mutation/createlabel/ and https://developer.github.com/v4/previews/#labels-preview
485func (ge *githubExporter) createGithubLabel(label, color string) (string, error) {
486	url := fmt.Sprintf("%s/repos/%s/%s/labels", githubV3Url, ge.conf[keyOwner], ge.conf[keyProject])
487
488	client := &http.Client{
489		Timeout: defaultTimeout,
490	}
491
492	params := struct {
493		Name        string `json:"name"`
494		Color       string `json:"color"`
495		Description string `json:"description"`
496	}{
497		Name:  label,
498		Color: color,
499	}
500
501	data, err := json.Marshal(params)
502	if err != nil {
503		return "", err
504	}
505
506	req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
507	if err != nil {
508		return "", err
509	}
510
511	// need the token for private repositories
512	req.Header.Set("Authorization", fmt.Sprintf("token %s", ge.conf[keyToken]))
513
514	resp, err := client.Do(req)
515	if err != nil {
516		return "", err
517	}
518
519	if resp.StatusCode != http.StatusCreated {
520		return "", fmt.Errorf("error creating label: response status %v", resp.StatusCode)
521	}
522
523	aux := struct {
524		ID     int    `json:"id"`
525		NodeID string `json:"node_id"`
526		Color  string `json:"color"`
527	}{}
528
529	data, _ = ioutil.ReadAll(resp.Body)
530	defer resp.Body.Close()
531
532	err = json.Unmarshal(data, &aux)
533	if err != nil {
534		return "", err
535	}
536
537	return aux.NodeID, nil
538}
539
540/**
541// create github label using api v4
542func (ge *githubExporter) createGithubLabelV4(gc *githubv4.Client, label, labelColor string) (string, error) {
543	m := createLabelMutation{}
544	input := createLabelInput{
545		RepositoryID: ge.repositoryID,
546		Name:         githubv4.String(label),
547		Color:        githubv4.String(labelColor),
548	}
549
550	parentCtx := context.Background()
551	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
552	defer cancel()
553
554	if err := gc.Mutate(ctx, &m, input, nil); err != nil {
555		return "", err
556	}
557
558	return m.CreateLabel.Label.ID, nil
559}
560*/
561
562func (ge *githubExporter) getOrCreateGithubLabelID(gc *githubv4.Client, repositoryID string, label bug.Label) (string, error) {
563	// try to get label id
564	labelID, err := ge.getGithubLabelID(gc, string(label))
565	if err == nil {
566		return labelID, nil
567	}
568
569	// RGBA to hex color
570	rgba := label.RGBA()
571	hexColor := fmt.Sprintf("%.2x%.2x%.2x", rgba.R, rgba.G, rgba.B)
572
573	labelID, err = ge.createGithubLabel(string(label), hexColor)
574	if err != nil {
575		return "", err
576	}
577
578	return labelID, nil
579}
580
581func (ge *githubExporter) getLabelsIDs(gc *githubv4.Client, repositoryID string, labels []bug.Label) ([]githubv4.ID, error) {
582	ids := make([]githubv4.ID, 0, len(labels))
583	var err error
584
585	// check labels ids
586	for _, label := range labels {
587		id, ok := ge.cachedLabels[string(label)]
588		if !ok {
589			// try to query label id
590			id, err = ge.getOrCreateGithubLabelID(gc, repositoryID, label)
591			if err != nil {
592				return nil, errors.Wrap(err, "get or create github label")
593			}
594
595			// cache label id
596			ge.cachedLabels[string(label)] = id
597		}
598
599		ids = append(ids, githubv4.ID(id))
600	}
601
602	return ids, nil
603}
604
605// create a github issue and return it ID
606func createGithubIssue(gc *githubv4.Client, repositoryID, title, body string) (string, string, error) {
607	m := &createIssueMutation{}
608	input := githubv4.CreateIssueInput{
609		RepositoryID: repositoryID,
610		Title:        githubv4.String(title),
611		Body:         (*githubv4.String)(&body),
612	}
613
614	parentCtx := context.Background()
615	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
616	defer cancel()
617
618	if err := gc.Mutate(ctx, m, input, nil); err != nil {
619		return "", "", err
620	}
621
622	issue := m.CreateIssue.Issue
623	return issue.ID, issue.URL, nil
624}
625
626// add a comment to an issue and return it ID
627func addCommentGithubIssue(gc *githubv4.Client, subjectID string, body string) (string, string, error) {
628	m := &addCommentToIssueMutation{}
629	input := githubv4.AddCommentInput{
630		SubjectID: subjectID,
631		Body:      githubv4.String(body),
632	}
633
634	parentCtx := context.Background()
635	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
636	defer cancel()
637
638	if err := gc.Mutate(ctx, m, input, nil); err != nil {
639		return "", "", err
640	}
641
642	node := m.AddComment.CommentEdge.Node
643	return node.ID, node.URL, nil
644}
645
646func editCommentGithubIssue(gc *githubv4.Client, commentID, body string) (string, string, error) {
647	m := &updateIssueCommentMutation{}
648	input := githubv4.UpdateIssueCommentInput{
649		ID:   commentID,
650		Body: githubv4.String(body),
651	}
652
653	parentCtx := context.Background()
654	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
655	defer cancel()
656
657	if err := gc.Mutate(ctx, m, input, nil); err != nil {
658		return "", "", err
659	}
660
661	return commentID, m.UpdateIssueComment.IssueComment.URL, nil
662}
663
664func updateGithubIssueStatus(gc *githubv4.Client, id string, status bug.Status) error {
665	m := &updateIssueMutation{}
666
667	// set state
668	var state githubv4.IssueState
669
670	switch status {
671	case bug.OpenStatus:
672		state = githubv4.IssueStateOpen
673	case bug.ClosedStatus:
674		state = githubv4.IssueStateOpen
675	default:
676		panic("unknown bug state")
677	}
678
679	input := githubv4.UpdateIssueInput{
680		ID:    id,
681		State: &state,
682	}
683
684	parentCtx := context.Background()
685	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
686	defer cancel()
687
688	if err := gc.Mutate(ctx, m, input, nil); err != nil {
689		return err
690	}
691
692	return nil
693}
694
695func updateGithubIssueBody(gc *githubv4.Client, id string, body string) error {
696	m := &updateIssueMutation{}
697	input := githubv4.UpdateIssueInput{
698		ID:   id,
699		Body: (*githubv4.String)(&body),
700	}
701
702	parentCtx := context.Background()
703	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
704	defer cancel()
705
706	if err := gc.Mutate(ctx, m, input, nil); err != nil {
707		return err
708	}
709
710	return nil
711}
712
713func updateGithubIssueTitle(gc *githubv4.Client, id, title string) error {
714	m := &updateIssueMutation{}
715	input := githubv4.UpdateIssueInput{
716		ID:    id,
717		Title: (*githubv4.String)(&title),
718	}
719
720	parentCtx := context.Background()
721	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
722	defer cancel()
723
724	if err := gc.Mutate(ctx, m, input, nil); err != nil {
725		return err
726	}
727
728	return nil
729}
730
731// update github issue labels
732func (ge *githubExporter) updateGithubIssueLabels(gc *githubv4.Client, labelableID string, added, removed []bug.Label) error {
733	addedIDs, err := ge.getLabelsIDs(gc, labelableID, added)
734	if err != nil {
735		return errors.Wrap(err, "getting added labels ids")
736	}
737
738	m := &addLabelsToLabelableMutation{}
739	inputAdd := githubv4.AddLabelsToLabelableInput{
740		LabelableID: labelableID,
741		LabelIDs:    addedIDs,
742	}
743
744	parentCtx := context.Background()
745	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
746
747	// add labels
748	if err := gc.Mutate(ctx, m, inputAdd, nil); err != nil {
749		cancel()
750		return err
751	}
752	cancel()
753
754	if len(removed) == 0 {
755		return nil
756	}
757
758	removedIDs, err := ge.getLabelsIDs(gc, labelableID, removed)
759	if err != nil {
760		return errors.Wrap(err, "getting added labels ids")
761	}
762
763	m2 := &removeLabelsFromLabelableMutation{}
764	inputRemove := githubv4.RemoveLabelsFromLabelableInput{
765		LabelableID: labelableID,
766		LabelIDs:    removedIDs,
767	}
768
769	ctx, cancel = context.WithTimeout(parentCtx, defaultTimeout)
770	defer cancel()
771
772	// remove label labels
773	if err := gc.Mutate(ctx, m2, inputRemove, nil); err != nil {
774		return err
775	}
776
777	return nil
778}