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	allIdentitiesIds := []string{}
126	for id := range ge.identityToken {
127		allIdentitiesIds = append(allIdentitiesIds, id)
128	}
129
130	allBugsIds := repo.AllBugsIds()
131
132	for _, id := range allBugsIds {
133		b, err := repo.ResolveBug(id)
134		if err != nil {
135			return err
136		}
137
138		snapshot := b.Snapshot()
139
140		// ignore issues created before since date
141		if snapshot.CreatedAt.Before(since) {
142			continue
143		}
144
145		if snapshot.HasAnyParticipant(allIdentitiesIds...) {
146			// try to export the bug and it associated events
147			if err := ge.exportBug(b, since); err != nil {
148				return err
149			}
150		}
151
152	}
153
154	fmt.Printf("Successfully exported %d issues and %d labels to Github\n", ge.exportedBugs, ge.exportedLabels)
155	return nil
156}
157
158// exportBug publish bugs and related events
159func (ge *githubExporter) exportBug(b *cache.BugCache, since time.Time) error {
160	snapshot := b.Snapshot()
161
162	var bugGithubID string
163	var bugGithubURL string
164	var bugCreationHash string
165
166	// Special case:
167	// if a user try to export a bug that is not already exported to Github (or imported
168	// from Github) and we do not have the token of the bug author, there is nothing we can do.
169
170	// first operation is always createOp
171	createOp := snapshot.Operations[0].(*bug.CreateOperation)
172	author := snapshot.Author
173
174	// skip bug if origin is not allowed
175	origin, ok := createOp.GetMetadata(keyOrigin)
176	if ok && !ge.allowOrigin(origin) {
177		// TODO print a warn ?
178		return nil
179	}
180
181	// get github bug ID
182	githubID, ok := createOp.GetMetadata(keyGithubId)
183	if ok {
184		githubURL, ok := createOp.GetMetadata(keyGithubUrl)
185		if !ok {
186			// if we find github ID, github URL must be found too
187			panic("expected to find github issue URL")
188		}
189
190		// will be used to mark operation related to a bug as exported
191		bugGithubID = githubID
192		bugGithubURL = githubURL
193
194	} else {
195		// check that we have a token for operation author
196		client, err := ge.getIdentityClient(author.Id())
197		if err != nil {
198			// if bug is still not exported and we do not have the author stop the execution
199
200			fmt.Println("warning: skipping issue due to missing token for bug creator")
201			// this is not an error, don't export bug
202			return nil
203		}
204
205		// create bug
206		id, url, err := createGithubIssue(client, ge.repositoryID, createOp.Title, createOp.Message)
207		if err != nil {
208			return errors.Wrap(err, "exporting github issue")
209		}
210
211		// incr exported bugs
212		ge.exportedBugs++
213
214		hash, err := createOp.Hash()
215		if err != nil {
216			return errors.Wrap(err, "comment hash")
217		}
218
219		// mark bug creation operation as exported
220		if err := markOperationAsExported(b, hash, id, url); err != nil {
221			return errors.Wrap(err, "marking operation as exported")
222		}
223
224		// commit operation to avoid creating multiple issues with multiple pushes
225		if err := b.CommitAsNeeded(); err != nil {
226			return errors.Wrap(err, "bug commit")
227		}
228
229		// cache bug github ID and URL
230		bugGithubID = id
231		bugGithubURL = url
232	}
233
234	// get createOp hash
235	hash, err := createOp.Hash()
236	if err != nil {
237		return err
238	}
239
240	bugCreationHash = hash.String()
241
242	// cache operation github id
243	ge.cachedIDs[bugCreationHash] = bugGithubID
244
245	for _, op := range snapshot.Operations[1:] {
246		// ignore SetMetadata operations
247		if _, ok := op.(*bug.SetMetadataOperation); ok {
248			continue
249		}
250
251		// get operation hash
252		hash, err := op.Hash()
253		if err != nil {
254			return errors.Wrap(err, "operation hash")
255		}
256
257		// ignore imported (or exported) operations from github
258		// cache the ID of already exported or imported issues and events from Github
259		if id, ok := op.GetMetadata(keyGithubId); ok {
260			ge.cachedIDs[hash.String()] = id
261			continue
262		}
263
264		opAuthor := op.GetAuthor()
265		client, err := ge.getIdentityClient(opAuthor.Id())
266		if err != nil {
267			// don't export operation
268			continue
269		}
270
271		var id, url string
272		switch op.(type) {
273		case *bug.AddCommentOperation:
274			opr := op.(*bug.AddCommentOperation)
275
276			// send operation to github
277			id, url, err = addCommentGithubIssue(client, bugGithubID, opr.Message)
278			if err != nil {
279				return errors.Wrap(err, "adding comment")
280			}
281
282			// cache comment id
283			ge.cachedIDs[hash.String()] = id
284
285		case *bug.EditCommentOperation:
286
287			opr := op.(*bug.EditCommentOperation)
288			targetHash := opr.Target.String()
289
290			// Since github doesn't consider the issue body as a comment
291			if targetHash == bugCreationHash {
292
293				// case bug creation operation: we need to edit the Github issue
294				if err := updateGithubIssueBody(client, bugGithubID, opr.Message); err != nil {
295					return errors.Wrap(err, "editing issue")
296				}
297
298				id = bugGithubID
299				url = bugGithubURL
300
301			} else {
302
303				// case comment edition operation: we need to edit the Github comment
304				commentID, ok := ge.cachedIDs[targetHash]
305				if !ok {
306					panic("unexpected error: comment id not found")
307				}
308
309				eid, eurl, err := editCommentGithubIssue(client, commentID, opr.Message)
310				if err != nil {
311					return errors.Wrap(err, "editing comment")
312				}
313
314				// use comment id/url instead of issue id/url
315				id = eid
316				url = eurl
317			}
318
319		case *bug.SetStatusOperation:
320			opr := op.(*bug.SetStatusOperation)
321			if err := updateGithubIssueStatus(client, bugGithubID, opr.Status); err != nil {
322				return errors.Wrap(err, "editing status")
323			}
324
325			id = bugGithubID
326			url = bugGithubURL
327
328		case *bug.SetTitleOperation:
329			opr := op.(*bug.SetTitleOperation)
330			if err := updateGithubIssueTitle(client, bugGithubID, opr.Title); err != nil {
331				return errors.Wrap(err, "editing title")
332			}
333
334			id = bugGithubID
335			url = bugGithubURL
336
337		case *bug.LabelChangeOperation:
338			opr := op.(*bug.LabelChangeOperation)
339			if err := ge.updateGithubIssueLabels(client, bugGithubID, opr.Added, opr.Removed); err != nil {
340				return errors.Wrap(err, "updating labels")
341			}
342
343			id = bugGithubID
344			url = bugGithubURL
345
346		default:
347			panic("unhandled operation type case")
348		}
349
350		// mark operation as exported
351		if err := markOperationAsExported(b, hash, id, url); err != nil {
352			return errors.Wrap(err, "marking operation as exported")
353		}
354
355		// commit at each operation export to avoid exporting same events multiple times
356		if err := b.CommitAsNeeded(); err != nil {
357			return errors.Wrap(err, "bug commit")
358		}
359	}
360
361	return nil
362}
363
364// getRepositoryNodeID request github api v3 to get repository node id
365func getRepositoryNodeID(owner, project, token string) (string, error) {
366	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
367
368	client := &http.Client{
369		Timeout: defaultTimeout,
370	}
371
372	req, err := http.NewRequest("GET", url, nil)
373	if err != nil {
374		return "", err
375	}
376
377	// need the token for private repositories
378	req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
379
380	resp, err := client.Do(req)
381	if err != nil {
382		return "", err
383	}
384
385	if resp.StatusCode != http.StatusOK {
386		return "", fmt.Errorf("error retrieving repository node id %v", resp.StatusCode)
387	}
388
389	aux := struct {
390		NodeID string `json:"node_id"`
391	}{}
392
393	data, _ := ioutil.ReadAll(resp.Body)
394	defer resp.Body.Close()
395
396	err = json.Unmarshal(data, &aux)
397	if err != nil {
398		return "", err
399	}
400
401	return aux.NodeID, nil
402}
403
404func markOperationAsExported(b *cache.BugCache, target git.Hash, githubID, githubURL string) error {
405	_, err := b.SetMetadata(
406		target,
407		map[string]string{
408			keyGithubId:  githubID,
409			keyGithubUrl: githubURL,
410		},
411	)
412
413	return err
414}
415
416// get label from github
417func (ge *githubExporter) getGithubLabelID(gc *githubv4.Client, label string) (string, error) {
418	q := &labelQuery{}
419	variables := map[string]interface{}{
420		"label": githubv4.String(label),
421		"owner": githubv4.String(ge.conf[keyOwner]),
422		"name":  githubv4.String(ge.conf[keyProject]),
423	}
424
425	parentCtx := context.Background()
426	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
427	defer cancel()
428
429	if err := gc.Query(ctx, q, variables); err != nil {
430		return "", err
431	}
432
433	// if label id is empty, it means there is no such label in this Github repository
434	if q.Repository.Label.ID == "" {
435		return "", fmt.Errorf("label not found")
436	}
437
438	return q.Repository.Label.ID, nil
439}
440
441// create a new label and return it github id
442// NOTE: since createLabel mutation is still in preview mode we use github api v3 to create labels
443// see https://developer.github.com/v4/mutation/createlabel/ and https://developer.github.com/v4/previews/#labels-preview
444func (ge *githubExporter) createGithubLabel(label, color string) (string, error) {
445	url := fmt.Sprintf("%s/repos/%s/%s/labels", githubV3Url, ge.conf[keyOwner], ge.conf[keyProject])
446
447	client := &http.Client{
448		Timeout: defaultTimeout,
449	}
450
451	params := struct {
452		Name        string `json:"name"`
453		Color       string `json:"color"`
454		Description string `json:"description"`
455	}{
456		Name:  label,
457		Color: color,
458	}
459
460	data, err := json.Marshal(params)
461	if err != nil {
462		return "", err
463	}
464
465	req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
466	if err != nil {
467		return "", err
468	}
469
470	// need the token for private repositories
471	req.Header.Set("Authorization", fmt.Sprintf("token %s", ge.conf[keyToken]))
472
473	resp, err := client.Do(req)
474	if err != nil {
475		return "", err
476	}
477
478	if resp.StatusCode != http.StatusCreated {
479		return "", fmt.Errorf("error creating label: response status %v", resp.StatusCode)
480	}
481
482	aux := struct {
483		ID     int    `json:"id"`
484		NodeID string `json:"node_id"`
485		Color  string `json:"color"`
486	}{}
487
488	data, _ = ioutil.ReadAll(resp.Body)
489	defer resp.Body.Close()
490
491	err = json.Unmarshal(data, &aux)
492	if err != nil {
493		return "", err
494	}
495
496	return aux.NodeID, nil
497}
498
499/**
500// create github label using api v4
501func (ge *githubExporter) createGithubLabelV4(gc *githubv4.Client, label, labelColor string) (string, error) {
502	m := createLabelMutation{}
503	input := createLabelInput{
504		RepositoryID: ge.repositoryID,
505		Name:         githubv4.String(label),
506		Color:        githubv4.String(labelColor),
507	}
508
509	parentCtx := context.Background()
510	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
511	defer cancel()
512
513	if err := gc.Mutate(ctx, &m, input, nil); err != nil {
514		return "", err
515	}
516
517	return m.CreateLabel.Label.ID, nil
518}
519*/
520
521func (ge *githubExporter) getOrCreateGithubLabelID(gc *githubv4.Client, repositoryID string, label bug.Label) (string, error) {
522	// try to get label id
523	labelID, err := ge.getGithubLabelID(gc, string(label))
524	if err == nil {
525		return labelID, nil
526	}
527
528	// RGBA to hex color
529	rgba := label.RGBA()
530	hexColor := fmt.Sprintf("%.2x%.2x%.2x", rgba.R, rgba.G, rgba.B)
531
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		return nil
710	}
711
712	removedIDs, err := ge.getLabelsIDs(gc, labelableID, removed)
713	if err != nil {
714		return errors.Wrap(err, "getting added labels ids")
715	}
716
717	m2 := &removeLabelsFromLabelableMutation{}
718	inputRemove := githubv4.RemoveLabelsFromLabelableInput{
719		LabelableID: labelableID,
720		LabelIDs:    removedIDs,
721	}
722
723	ctx, cancel = context.WithTimeout(parentCtx, defaultTimeout)
724	defer cancel()
725
726	// remove label labels
727	if err := gc.Mutate(ctx, m2, inputRemove, nil); err != nil {
728		return err
729	}
730
731	return nil
732}