export.go

  1package jira
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"net/http"
  8	"time"
  9
 10	"github.com/pkg/errors"
 11
 12	"github.com/MichaelMure/git-bug/bridge/core"
 13	"github.com/MichaelMure/git-bug/bug"
 14	"github.com/MichaelMure/git-bug/cache"
 15	"github.com/MichaelMure/git-bug/entity"
 16)
 17
 18// jiraExporter implement the Exporter interface
 19type jiraExporter struct {
 20	conf core.Configuration
 21
 22	// cache identities clients
 23	identityClient map[entity.Id]*Client
 24
 25	// cache identifiers used to speed up exporting operations
 26	// cleared for each bug
 27	cachedOperationIDs map[entity.Id]string
 28
 29	// cache labels used to speed up exporting labels events
 30	cachedLabels map[string]string
 31
 32	// store JIRA project information
 33	project *Project
 34}
 35
 36// Init .
 37func (self *jiraExporter) Init(conf core.Configuration) error {
 38	self.conf = conf
 39	//TODO: initialize with multiple tokens
 40	self.identityClient = make(map[entity.Id]*Client)
 41	self.cachedOperationIDs = make(map[entity.Id]string)
 42	self.cachedLabels = make(map[string]string)
 43	return nil
 44}
 45
 46// getIdentityClient return an API client configured with the credentials
 47// of the given identity. If no client were found it will initialize it from
 48// the known credentials map and cache it for next use
 49func (self *jiraExporter) getIdentityClient(
 50	ctx *context.Context, id entity.Id) (*Client, error) {
 51	client, ok := self.identityClient[id]
 52	if ok {
 53		return client, nil
 54	}
 55
 56	// TODO(josh)[]: The github exporter appears to contain code that will
 57	// allow it to export bugs owned by other people as long as we have a token
 58	// for that identity. I guess the equivalent for us will be as long as we
 59	// have a credentials pair for that identity.
 60	return nil, fmt.Errorf("Not implemented")
 61}
 62
 63// ExportAll export all event made by the current user to Jira
 64func (self *jiraExporter) ExportAll(
 65	ctx context.Context, repo *cache.RepoCache, since time.Time) (
 66	<-chan core.ExportResult, error) {
 67
 68	out := make(chan core.ExportResult)
 69
 70	user, err := repo.GetUserIdentity()
 71	if err != nil {
 72		return nil, err
 73	}
 74
 75	// TODO(josh)[]: The github exporter appears to contain code that will
 76	// allow it to export bugs owned by other people as long as we have a token
 77	// for that identity. I guess the equivalent for us will be as long as we
 78	// have a credentials pair for that identity.
 79	client := NewClient(self.conf[keyServer], &ctx)
 80	err = client.Login(self.conf)
 81	self.identityClient[user.Id()] = client
 82
 83	if err != nil {
 84		return nil, err
 85	}
 86
 87	client, err = self.getIdentityClient(&ctx, user.Id())
 88	if err != nil {
 89		return nil, err
 90	}
 91
 92	self.project, err = client.GetProject(self.conf[keyProject])
 93	if err != nil {
 94		return nil, err
 95	}
 96
 97	go func() {
 98		defer close(out)
 99
100		var allIdentitiesIds []entity.Id
101		for id := range self.identityClient {
102			allIdentitiesIds = append(allIdentitiesIds, id)
103		}
104
105		allBugsIds := repo.AllBugsIds()
106
107		for _, id := range allBugsIds {
108			b, err := repo.ResolveBug(id)
109			if err != nil {
110				out <- core.NewExportError(errors.Wrap(err, "can't load bug"), id)
111				return
112			}
113
114			select {
115
116			case <-ctx.Done():
117				// stop iterating if context cancel function is called
118				return
119
120			default:
121				snapshot := b.Snapshot()
122
123				// ignore issues whose last modification date is before the query date
124				// TODO: compare the Lamport time instead of using the unix time
125				if snapshot.CreatedAt.Before(since) {
126					out <- core.NewExportNothing(b.Id(), "bug created before the since date")
127					continue
128				}
129
130				if snapshot.HasAnyActor(allIdentitiesIds...) {
131					// try to export the bug and it associated events
132					self.exportBug(ctx, b, since, out)
133				} else {
134					out <- core.NewExportNothing(id, "not an actor")
135				}
136			}
137		}
138	}()
139
140	return out, nil
141}
142
143// exportBug publish bugs and related events
144func (self *jiraExporter) exportBug(
145	ctx context.Context, b *cache.BugCache, since time.Time,
146	out chan<- core.ExportResult) {
147	snapshot := b.Snapshot()
148
149	var bugJiraID string
150
151	// Special case:
152	// if a user try to export a bug that is not already exported to jira (or
153	// imported from jira) and we do not have the token of the bug author,
154	// there is nothing we can do.
155
156	// first operation is always createOp
157	createOp := snapshot.Operations[0].(*bug.CreateOperation)
158	author := snapshot.Author
159
160	// skip bug if it was imported from some other bug system
161	origin, ok := snapshot.GetCreateMetadata(keyOrigin)
162	if ok && origin != target {
163		out <- core.NewExportNothing(
164			b.Id(), fmt.Sprintf("issue tagged with origin: %s", origin))
165		return
166	}
167
168	// skip bug if it is a jira bug but is associated with another project
169	// (one bridge per JIRA project)
170	project, ok := snapshot.GetCreateMetadata(keyJiraProject)
171	if ok && !stringInSlice(project, []string{self.project.ID, self.project.Key}) {
172		out <- core.NewExportNothing(
173			b.Id(), fmt.Sprintf("issue tagged with project: %s", project))
174		return
175	}
176
177	// get jira bug ID
178	jiraID, ok := snapshot.GetCreateMetadata(keyJiraID)
179	if ok {
180		out <- core.NewExportNothing(b.Id(), "bug creation already exported")
181		// will be used to mark operation related to a bug as exported
182		bugJiraID = jiraID
183	} else {
184		// check that we have credentials for operation author
185		client, err := self.getIdentityClient(&ctx, author.Id())
186		if err != nil {
187			// if bug is not yet exported and we do not have the author's credentials
188			// then there is nothing we can do, so just skip this bug
189			out <- core.NewExportNothing(
190				b.Id(), fmt.Sprintf("missing author token for user %.8s",
191					author.Id().String()))
192			return
193		}
194
195		// Load any custom fields required to create an issue from the git
196		// config file.
197		fields := make(map[string]interface{})
198		defaultFields, hasConf := self.conf[keyCreateDefaults]
199		if hasConf {
200			err = json.Unmarshal([]byte(defaultFields), &fields)
201			if err != nil {
202				panic("Invalid JSON in config")
203			}
204		} else {
205			// If there is no configuration provided, at the very least the
206			// "issueType" field is always required. 10001 is "story" which I'm
207			// pretty sure is standard/default on all JIRA instances.
208			fields["issueType"] = "10001"
209		}
210		bugIDField, hasConf := self.conf[keyCreateGitBug]
211		if hasConf {
212			// If the git configuration also indicates it, we can assign the git-bug
213			// id to a custom field to assist in integrations
214			fields[bugIDField] = b.Id().String()
215		}
216
217		// create bug
218		result, err := client.CreateIssue(
219			self.project.ID, createOp.Title, createOp.Message, fields)
220		if err != nil {
221			err := errors.Wrap(err, "exporting jira issue")
222			out <- core.NewExportError(err, b.Id())
223			return
224		}
225
226		id := result.ID
227		out <- core.NewExportBug(b.Id())
228		// mark bug creation operation as exported
229		err = markOperationAsExported(
230			b, createOp.Id(), id, self.project.Key, time.Time{})
231		if err != nil {
232			err := errors.Wrap(err, "marking operation as exported")
233			out <- core.NewExportError(err, b.Id())
234			return
235		}
236
237		// commit operation to avoid creating multiple issues with multiple pushes
238		err = b.CommitAsNeeded()
239		if err != nil {
240			err := errors.Wrap(err, "bug commit")
241			out <- core.NewExportError(err, b.Id())
242			return
243		}
244
245		// cache bug jira ID
246		bugJiraID = id
247	}
248
249	// cache operation jira id
250	self.cachedOperationIDs[createOp.Id()] = bugJiraID
251
252	// lookup the mapping from git-bug "status" to JIRA "status" id
253	statusMap := getStatusMap(self.conf)
254
255	for _, op := range snapshot.Operations[1:] {
256		// ignore SetMetadata operations
257		if _, ok := op.(*bug.SetMetadataOperation); ok {
258			continue
259		}
260
261		// ignore operations already existing in jira (due to import or export)
262		// cache the ID of already exported or imported issues and events from
263		// Jira
264		if id, ok := op.GetMetadata(keyJiraID); ok {
265			self.cachedOperationIDs[op.Id()] = id
266			out <- core.NewExportNothing(op.Id(), "already exported operation")
267			continue
268		}
269
270		opAuthor := op.GetAuthor()
271		client, err := self.getIdentityClient(&ctx, opAuthor.Id())
272		if err != nil {
273			out <- core.NewExportNothing(
274				op.Id(), fmt.Sprintf(
275					"missing operation author credentials for user %.8s",
276					author.Id().String()))
277			continue
278		}
279
280		var id string
281		var exportTime time.Time
282		switch opr := op.(type) {
283		case *bug.AddCommentOperation:
284			comment, err := client.AddComment(bugJiraID, opr.Message)
285			if err != nil {
286				err := errors.Wrap(err, "adding comment")
287				out <- core.NewExportError(err, b.Id())
288				return
289			}
290			id = comment.ID
291			out <- core.NewExportComment(op.Id())
292
293			// cache comment id
294			self.cachedOperationIDs[op.Id()] = id
295
296		case *bug.EditCommentOperation:
297			if opr.Target == createOp.Id() {
298				// An EditCommentOpreation with the Target set to the create operation
299				// encodes a modification to the long-description/summary.
300				exportTime, err = client.UpdateIssueBody(bugJiraID, opr.Message)
301				if err != nil {
302					err := errors.Wrap(err, "editing issue")
303					out <- core.NewExportError(err, b.Id())
304					return
305				}
306				out <- core.NewExportCommentEdition(op.Id())
307				id = bugJiraID
308			} else {
309				// Otherwise it's an edit to an actual comment. A comment cannot be
310				// edited before it was created, so it must be the case that we have
311				// already observed and cached the AddCommentOperation.
312				commentID, ok := self.cachedOperationIDs[opr.Target]
313				if !ok {
314					// Since an edit has to come after the creation, we expect we would
315					// have cached the creation id.
316					panic("unexpected error: comment id not found")
317				}
318				comment, err := client.UpdateComment(bugJiraID, commentID, opr.Message)
319				if err != nil {
320					err := errors.Wrap(err, "editing comment")
321					out <- core.NewExportError(err, b.Id())
322					return
323				}
324				out <- core.NewExportCommentEdition(op.Id())
325				// JIRA doesn't track all comment edits, they will only tell us about
326				// the most recent one. We must invent a consistent id for the operation
327				// so we use the comment ID plus the timestamp of the update, as
328				// reported by JIRA. Note that this must be consistent with the importer
329				// during ensureComment()
330				id = fmt.Sprintf("%s-%d", comment.ID, comment.Updated.Unix())
331			}
332
333		case *bug.SetStatusOperation:
334			jiraStatus, hasStatus := statusMap[opr.Status.String()]
335			if hasStatus {
336				exportTime, err = UpdateIssueStatus(client, bugJiraID, jiraStatus)
337				if err != nil {
338					err := errors.Wrap(err, "editing status")
339					out <- core.NewExportError(err, b.Id())
340					// Failure to update status isn't necessarily a big error. It's
341					// possible that we just don't have enough information to make that
342					// update. In this case, just don't export the operation.
343					continue
344				}
345				out <- core.NewExportStatusChange(op.Id())
346				// TODO(josh)[c2c6767]: query changelog to get the changelog-id so that
347				// we don't re-import the same change.
348				id = bugJiraID
349			} else {
350				out <- core.NewExportNothing(
351					op.Id(), fmt.Sprintf(
352						"No jira status mapped for %.8s", opr.Status.String()))
353			}
354
355		case *bug.SetTitleOperation:
356			exportTime, err = client.UpdateIssueTitle(bugJiraID, opr.Title)
357			if err != nil {
358				err := errors.Wrap(err, "editing title")
359				out <- core.NewExportError(err, b.Id())
360				return
361			}
362			out <- core.NewExportTitleEdition(op.Id())
363			// TODO(josh)[c2c6767]: query changelog to get the changelog-id so that
364			// we don't re-import the same change.
365			id = bugJiraID
366
367		case *bug.LabelChangeOperation:
368			exportTime, err = client.UpdateLabels(
369				bugJiraID, opr.Added, opr.Removed)
370			if err != nil {
371				err := errors.Wrap(err, "updating labels")
372				out <- core.NewExportError(err, b.Id())
373				return
374			}
375			out <- core.NewExportLabelChange(op.Id())
376			// TODO(josh)[c2c6767]: query changelog to get the changelog-id so that
377			// we don't re-import the same change.
378			id = bugJiraID
379
380		default:
381			panic("unhandled operation type case")
382		}
383
384		// mark operation as exported
385		// TODO(josh)[c2c6767]: Should we query the changelog after we export?
386		// Some of the operations above don't record an ID... so we are bound to
387		// re-import them. It shouldn't cause too much of an issue but we will have
388		// duplicate edit entries for everything and it would be nice to avoid that.
389		err = markOperationAsExported(
390			b, op.Id(), id, self.project.Key, exportTime)
391		if err != nil {
392			err := errors.Wrap(err, "marking operation as exported")
393			out <- core.NewExportError(err, b.Id())
394			return
395		}
396
397		// commit at each operation export to avoid exporting same events multiple
398		// times
399		err = b.CommitAsNeeded()
400		if err != nil {
401			err := errors.Wrap(err, "bug commit")
402			out <- core.NewExportError(err, b.Id())
403			return
404		}
405	}
406}
407
408func markOperationAsExported(
409	b *cache.BugCache, target entity.Id, jiraID, jiraProject string,
410	exportTime time.Time) error {
411
412	newMetadata := map[string]string{
413		keyJiraID:      jiraID,
414		keyJiraProject: jiraProject,
415	}
416	if !exportTime.IsZero() {
417		newMetadata[keyJiraExportTime] = exportTime.Format(http.TimeFormat)
418	}
419
420	_, err := b.SetMetadata(target, newMetadata)
421	return err
422}
423
424// UpdateIssueStatus attempts to change the "status" field by finding a
425// transition which achieves the desired state and then performing that
426// transition
427func UpdateIssueStatus(
428	client *Client, issueKeyOrID string, desiredStateNameOrID string) (
429	time.Time, error) {
430
431	var responseTime time.Time
432
433	tlist, err := client.GetTransitions(issueKeyOrID)
434	if err != nil {
435		return responseTime, err
436	}
437
438	transition := getTransitionTo(tlist, desiredStateNameOrID)
439	if transition == nil {
440		return responseTime, errTransitionNotFound
441	}
442
443	responseTime, err = client.DoTransition(issueKeyOrID, transition.ID)
444	if err != nil {
445		return responseTime, err
446	}
447
448	return responseTime, nil
449}