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