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