export.go

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