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