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], cred)
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.Author()
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}