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