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