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