export.go

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