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