export.go

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