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