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