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