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}