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