jirasync 0.2 → 0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/README.md +142 -1
- data/bin/jira-format-issues +195 -0
- data/bin/jira-sync +38 -4
- data/jirasync.gemspec +1 -1
- data/lib/jirasync/jira_client.rb +12 -3
- data/lib/jirasync/local_repository.rb +5 -0
- data/lib/jirasync/syncer.rb +8 -4
- data/lib/jirasync/version.rb +1 -1
- metadata +4 -2
data/README.md
CHANGED
@@ -1,2 +1,143 @@
|
|
1
1
|
# jira-sync
|
2
|
-
|
2
|
+
|
3
|
+
A suite of utilities to synchronise jira projects to the local file system
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
gem install jirasync
|
8
|
+
|
9
|
+
|
10
|
+
## Usage
|
11
|
+
|
12
|
+
### Initial Fetch
|
13
|
+
|
14
|
+
The following command will start synchronising a the project `MYPROJ` from the server at
|
15
|
+
`https://jira.myorganisation.com`. The issues from that project will be written to the
|
16
|
+
`issues/MYPROJ` folder:
|
17
|
+
|
18
|
+
|
19
|
+
jira-sync \
|
20
|
+
--baseurl https://jira.myorganisation.com \
|
21
|
+
--project MYPROJ \
|
22
|
+
--user jira_user \
|
23
|
+
--password jira_password \
|
24
|
+
--target issues/MYPROJ/json \
|
25
|
+
fetch
|
26
|
+
|
27
|
+
|
28
|
+
When this passes successfully the `issues/MYPROJ/json` directory will contain the following structure:
|
29
|
+
|
30
|
+
MYPROJ-1.json
|
31
|
+
MYPROJ-2.json
|
32
|
+
MYPROJ-3.json
|
33
|
+
MYPROJ-4.json
|
34
|
+
…
|
35
|
+
sync_state.json
|
36
|
+
|
37
|
+
Each issue file contains a pretty-printed json representation of the ticket. The modified date of the files is set to
|
38
|
+
the value of the `updated` field of the corresponding ticket, so that a makefile can be used to render the
|
39
|
+
json files incrementally into a more readable representation.
|
40
|
+
|
41
|
+
The `sync_state.json` file contains information about the last sync, such as the time and errors that occurred.
|
42
|
+
|
43
|
+
### Updating
|
44
|
+
|
45
|
+
The following statement will sync issues that have changed or where added during the last sync:
|
46
|
+
|
47
|
+
jira-sync \
|
48
|
+
--baseurl https://jira.myorganisation.com \
|
49
|
+
--project MYPROJ \
|
50
|
+
--user jira_user \
|
51
|
+
--password jira_password \
|
52
|
+
--target issues/MYPROJ/json \
|
53
|
+
update
|
54
|
+
|
55
|
+
|
56
|
+
### Formatting Issues
|
57
|
+
|
58
|
+
While json files are very handy to use in code, they are not very readable. The `jira-format-issues` command
|
59
|
+
formats json issues to markdown. It is invoked as follows:
|
60
|
+
|
61
|
+
jira-format-issues \
|
62
|
+
--source issues/MYPROJECT/json \
|
63
|
+
--target issues/MYPROJECT/markdown
|
64
|
+
|
65
|
+
This will create the following structure in the `issues/MYPROJ/markdown`:
|
66
|
+
|
67
|
+
MYPROJ-1.md
|
68
|
+
MYPROJ-2.md
|
69
|
+
MYPROJ-3.md
|
70
|
+
MYPROJ-4.md
|
71
|
+
…
|
72
|
+
|
73
|
+
|
74
|
+
The individual files look like this:
|
75
|
+
|
76
|
+
[MYPROJ-1](https://jira.myorganisation.co/browse/MYPROJ-1): Build a working System
|
77
|
+
==================================================================================
|
78
|
+
|
79
|
+
Type
|
80
|
+
: Story
|
81
|
+
|
82
|
+
Status
|
83
|
+
: Closed
|
84
|
+
|
85
|
+
Reporter
|
86
|
+
: fleipold
|
87
|
+
|
88
|
+
Labels
|
89
|
+
: triaged
|
90
|
+
|
91
|
+
Updated
|
92
|
+
: 20. Jan 2014 11:30 (UTC)
|
93
|
+
|
94
|
+
Created
|
95
|
+
: 01. Aug 2013 12:29 (UTC)
|
96
|
+
|
97
|
+
|
98
|
+
Description
|
99
|
+
-----------
|
100
|
+
|
101
|
+
The myproj system shall be built to be *delpoyable* and *working*.
|
102
|
+
|
103
|
+
|
104
|
+
Comments
|
105
|
+
--------
|
106
|
+
|
107
|
+
### fleipold - 20. Jan 2014 12:19 (UTC):
|
108
|
+
|
109
|
+
Is this still relevant?
|
110
|
+
|
111
|
+
|
112
|
+
These files can be easily searched by ensuring they get indexed by a desktop search engine, e.g.
|
113
|
+
[spotlight](https://gist.github.com/gereon/3150445) on the Mac.
|
114
|
+
|
115
|
+
There is also the possibility to render custom jira fields by supplying a *custom data* file, which declares *simple
|
116
|
+
data* fields which are rendered as definitions at the top of the ticket and *sections* that are rendered as paragraph
|
117
|
+
with a heading. Here is an example file, `custom-data.json`:
|
118
|
+
|
119
|
+
{
|
120
|
+
"simple_fields" : {
|
121
|
+
"Audience" : ["customfield_10123", "value"]
|
122
|
+
},
|
123
|
+
"sections" : {
|
124
|
+
"Release Notes" : ["customfield_10806"]
|
125
|
+
}
|
126
|
+
}
|
127
|
+
|
128
|
+
This file can be passed in like this:
|
129
|
+
|
130
|
+
jira-format-issues \
|
131
|
+
--source issues/MYPROJECT/json \
|
132
|
+
--target issues/MYPROJECT/markdown \
|
133
|
+
--custom-data-path custom-data.json
|
134
|
+
|
135
|
+
## Motivation
|
136
|
+
|
137
|
+
Having a local, unix-friendly copy to avoid jira performance issues and make information available offline.
|
138
|
+
|
139
|
+
## Potential Future Work
|
140
|
+
|
141
|
+
* Remove tickets that have been moved to a different project
|
142
|
+
* Use OAuth authentication
|
143
|
+
* Improved error handling
|
@@ -0,0 +1,195 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'fileutils'
|
4
|
+
require 'json'
|
5
|
+
require 'trollop'
|
6
|
+
require 'pathname'
|
7
|
+
require 'uri'
|
8
|
+
|
9
|
+
|
10
|
+
class IssueRenderer
|
11
|
+
|
12
|
+
def initialize(custom_data)
|
13
|
+
@custom_data = custom_data
|
14
|
+
end
|
15
|
+
|
16
|
+
def h1(s)
|
17
|
+
s + "\n" + ("=" * s.length)
|
18
|
+
end
|
19
|
+
|
20
|
+
def h2(s)
|
21
|
+
s + "\n" + ("-" * s.length)
|
22
|
+
end
|
23
|
+
|
24
|
+
def wrap(s, width=78)
|
25
|
+
s.gsub(/(.{1,#{width}})(\s+|\Z)/, "\\1\n")
|
26
|
+
end
|
27
|
+
|
28
|
+
def optional_section(title, content)
|
29
|
+
return "" if !content
|
30
|
+
return "" if content == ""
|
31
|
+
h2(title) + "\n\n" + content
|
32
|
+
end
|
33
|
+
|
34
|
+
def format_date(date)
|
35
|
+
date.strftime("%d. %b %Y %H:%M (UTC)")
|
36
|
+
end
|
37
|
+
|
38
|
+
def render_comments(comments)
|
39
|
+
return "" if comments.length == 0
|
40
|
+
|
41
|
+
|
42
|
+
h2("Comments") + "\n" * 2 +
|
43
|
+
(comments.map{|comment|
|
44
|
+
author = comment['author']['name']
|
45
|
+
date = DateTime.parse(comment['updated'])
|
46
|
+
"### #{author} - #{format_date(date)}:\n\n" +
|
47
|
+
comment['body'] + "\n"
|
48
|
+
}.join("\n-------------------------------------------\n\n"))
|
49
|
+
end
|
50
|
+
|
51
|
+
def render_links(links, project_key)
|
52
|
+
return "" if links.length == 0
|
53
|
+
|
54
|
+
h2("Links") + "\n" * 2 +
|
55
|
+
links.map{|link|
|
56
|
+
outward_issue = link['outwardIssue']
|
57
|
+
inward_issue = link['inwardIssue']
|
58
|
+
rel_name = link['type']['name']
|
59
|
+
inward_name = link['type']['inward']
|
60
|
+
outward_name = link['type']['outward']
|
61
|
+
if (outward_issue)
|
62
|
+
uri = URI(outward_issue['self'])
|
63
|
+
key = outward_issue['key']
|
64
|
+
uri.path = "/browse/#{key}"
|
65
|
+
|
66
|
+
if (key.start_with?(project_key))
|
67
|
+
link = key + ".md"
|
68
|
+
else
|
69
|
+
link = uri.to_s
|
70
|
+
end
|
71
|
+
"* " + outward_name.capitalize + " [#{key}](#{link})\n"
|
72
|
+
else
|
73
|
+
uri = URI(inward_issue['self'])
|
74
|
+
key = inward_issue['key']
|
75
|
+
uri.path = "/browse/#{key}"
|
76
|
+
if (key.start_with?(project_key))
|
77
|
+
link = key + ".md"
|
78
|
+
else
|
79
|
+
link = uri.to_s
|
80
|
+
end
|
81
|
+
|
82
|
+
"* " + inward_name.capitalize + " [#{key}](#{link.to_s})\n"
|
83
|
+
end
|
84
|
+
}.join("\n" * 2)
|
85
|
+
end
|
86
|
+
|
87
|
+
def render_attachments(attachments)
|
88
|
+
return "" if attachments.length == 0
|
89
|
+
|
90
|
+
h2("Attachments") + "\n" * 2 +
|
91
|
+
attachments.map{|attachment|
|
92
|
+
filename = attachment['filename']
|
93
|
+
content_url = attachment['content']
|
94
|
+
"* [#{filename}](#{content_url})"
|
95
|
+
}.join("\n" * 2)
|
96
|
+
end
|
97
|
+
|
98
|
+
def render(issue)
|
99
|
+
|
100
|
+
|
101
|
+
fields = issue['fields']
|
102
|
+
type = fields['issuetype']['name']
|
103
|
+
key = issue['key']
|
104
|
+
summary = fields['summary']
|
105
|
+
status = fields['status']['name']
|
106
|
+
reporter = fields['reporter']['name']
|
107
|
+
description = fields['description']
|
108
|
+
labels = fields['labels']
|
109
|
+
comments = fields['comment']['comments']
|
110
|
+
links = fields['issuelinks']
|
111
|
+
attachments = fields['attachment']
|
112
|
+
project_key = fields['project']['key']
|
113
|
+
|
114
|
+
created = DateTime.parse(fields['created'])
|
115
|
+
updated = DateTime.parse(fields['updated'])
|
116
|
+
|
117
|
+
uri = URI(issue['self'])
|
118
|
+
uri.path = "/browse/#{key}"
|
119
|
+
|
120
|
+
simple_fields = {
|
121
|
+
"Type" => type,
|
122
|
+
"Status" => status,
|
123
|
+
"Reporter" => reporter,
|
124
|
+
"Labels" => labels.join(", "),
|
125
|
+
"Updated" => format_date(updated),
|
126
|
+
"Created" => format_date(created)
|
127
|
+
}
|
128
|
+
|
129
|
+
simple_fields = simple_fields.to_a + @custom_data['simple_fields'].map{|kv| [kv[0], kv[1].inject(fields) { |acc, key | acc && acc[key] }]}
|
130
|
+
|
131
|
+
sections = {
|
132
|
+
"Description" => description,
|
133
|
+
}
|
134
|
+
|
135
|
+
sections = sections.to_a + @custom_data['sections'].map{|kv| [kv[0], kv[1].inject(fields) { |acc, key | acc && acc[key] }]}
|
136
|
+
|
137
|
+
return h1("[#{key}](#{uri}): #{summary}") +
|
138
|
+
"\n\n" +
|
139
|
+
simple_fields.map{|kv| kv[0] + "\n: " + (kv[1] || "-")}.join("\n\n") + "\n\n" +
|
140
|
+
sections.select{|kv| kv[1]}.map{|kv| optional_section(kv[0], kv[1])}.join("\n\n") + "\n\n" +
|
141
|
+
render_links(links, project_key) + "\n\n" +
|
142
|
+
render_comments(comments) + "\n\n" +
|
143
|
+
render_attachments(attachments)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
opts = Trollop::options do
|
148
|
+
banner <<-EOS
|
149
|
+
Utility to render jira issues from a source :directory to a target directory
|
150
|
+
|
151
|
+
Usage:
|
152
|
+
jira-format-issues [options]
|
153
|
+
|
154
|
+
where [options] are:
|
155
|
+
EOS
|
156
|
+
opt :source, "Source directory containing json files for issues", :type => :string
|
157
|
+
opt :target, "Target directory", :type => :string
|
158
|
+
opt :custom_data_path, "Custom data description file (optional)", :type => :string
|
159
|
+
end
|
160
|
+
|
161
|
+
Trollop::die :source, "must be speficied" if !opts[:source]
|
162
|
+
Trollop::die :target, "must be speficied" if !opts[:target]
|
163
|
+
|
164
|
+
source = opts[:source]
|
165
|
+
target = opts[:target]
|
166
|
+
|
167
|
+
FileUtils::mkdir_p target
|
168
|
+
|
169
|
+
|
170
|
+
input_files = Dir[source + "/*.json"]
|
171
|
+
|
172
|
+
custom_data_path = opts[:custom_data_path]
|
173
|
+
if (custom_data_path)
|
174
|
+
custom_data = JSON.parse(IO.read(custom_data_path))
|
175
|
+
else
|
176
|
+
custom_data = {'simple_fields' => [], 'sections' => []}
|
177
|
+
end
|
178
|
+
|
179
|
+
renderer = IssueRenderer.new(custom_data)
|
180
|
+
|
181
|
+
input_files.each do |file_path|
|
182
|
+
begin
|
183
|
+
basename = Pathname.new(file_path).basename(".json").to_s
|
184
|
+
if (basename == "sync_state")
|
185
|
+
next
|
186
|
+
end
|
187
|
+
output_file = target + "/" + basename + ".md"
|
188
|
+
issue = JSON.parse(IO.read(file_path))
|
189
|
+
markdown = renderer.render(issue)
|
190
|
+
File.write(output_file, markdown)
|
191
|
+
rescue => e
|
192
|
+
STDERR.puts("Problem rendering '#{file_path}'")
|
193
|
+
STDERR.puts(e.to_s)
|
194
|
+
end
|
195
|
+
end
|
data/bin/jira-sync
CHANGED
@@ -4,10 +4,8 @@ require 'rubygems'
|
|
4
4
|
require 'io/console'
|
5
5
|
require 'trollop'
|
6
6
|
|
7
|
-
|
8
7
|
require 'jirasync'
|
9
8
|
|
10
|
-
|
11
9
|
def prompt_for_password
|
12
10
|
print("Please enter password: ")
|
13
11
|
pw = STDIN.noecho(&:gets).chomp
|
@@ -43,6 +41,7 @@ Trollop::die :project, "must be speficied" if !opts[:project]
|
|
43
41
|
user = opts[:user] || ENV['USER']
|
44
42
|
pw = opts[:password] || prompt_for_password
|
45
43
|
target = opts[:target] || opts[:project].chomp
|
44
|
+
project = opts[:project]
|
46
45
|
|
47
46
|
command = ARGV[0]
|
48
47
|
|
@@ -53,12 +52,47 @@ end
|
|
53
52
|
|
54
53
|
client = JiraSync::JiraClient.new(opts[:baseurl], user, pw)
|
55
54
|
repo = JiraSync::LocalIssueRepository.new(target)
|
56
|
-
syncer = JiraSync::Syncer.new(client, repo,
|
55
|
+
syncer = JiraSync::Syncer.new(client, repo, project)
|
57
56
|
|
58
57
|
if command == "fetch"
|
58
|
+
if (repo.state_exists?)
|
59
|
+
update_statement=<<-END
|
60
|
+
jira-sync \
|
61
|
+
--baseurl #{opts[:baseurl]} \\
|
62
|
+
--user #{user} \\
|
63
|
+
--project #{project} \\
|
64
|
+
--target #{target} \\
|
65
|
+
update
|
66
|
+
END
|
67
|
+
|
68
|
+
STDERR.puts <<-END
|
69
|
+
Synchronisation state file found. Please use 'update' for incremental update
|
70
|
+
or sync to an empty directory. Try to use a statement along the following lines
|
71
|
+
#{update_statement}
|
72
|
+
END
|
73
|
+
exit 1
|
74
|
+
end
|
59
75
|
syncer.fetch_all
|
60
76
|
end
|
61
77
|
|
62
78
|
if command == "update"
|
79
|
+
if (!repo.state_exists?)
|
80
|
+
fetch_statement=<<-END
|
81
|
+
jira-sync \
|
82
|
+
--baseurl #{opts[:baseurl]} \\
|
83
|
+
--user #{user} \\
|
84
|
+
--project #{project} \\
|
85
|
+
--target #{target} \\
|
86
|
+
fetch
|
87
|
+
END
|
88
|
+
|
89
|
+
STDERR.puts <<-END
|
90
|
+
No synchronisation state file found. Please use 'fetch' for an initial download.
|
91
|
+
Try to use a statement along the following lines
|
92
|
+
#{fetch_statement}
|
93
|
+
END
|
94
|
+
exit 1
|
95
|
+
end
|
96
|
+
|
63
97
|
syncer.update
|
64
|
-
end
|
98
|
+
end
|
data/jirasync.gemspec
CHANGED
data/lib/jirasync/jira_client.rb
CHANGED
@@ -4,6 +4,15 @@ module JiraSync
|
|
4
4
|
require 'uri'
|
5
5
|
require 'json'
|
6
6
|
|
7
|
+
class FetchError < StandardError
|
8
|
+
attr_reader :status, :message
|
9
|
+
|
10
|
+
def initialize(status, message)
|
11
|
+
@reason = status
|
12
|
+
@message = message
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
7
16
|
|
8
17
|
class JiraClient
|
9
18
|
|
@@ -20,7 +29,7 @@ module JiraSync
|
|
20
29
|
if response.code == 200
|
21
30
|
response.parsed_response
|
22
31
|
else
|
23
|
-
raise "
|
32
|
+
raise FetchError(response.code, "error retrieving #{jira_id}. response code was #{response.code}, url was #{url}")
|
24
33
|
end
|
25
34
|
end
|
26
35
|
|
@@ -32,7 +41,7 @@ module JiraSync
|
|
32
41
|
if response.code == 200
|
33
42
|
response.parsed_response
|
34
43
|
else
|
35
|
-
raise "no issue found for #{project_id}. response code was #{response.code}, url was #{url}"
|
44
|
+
raise FetchError(response.status, "no issue found for #{project_id}. response code was #{response.code}, url was #{url}")
|
36
45
|
end
|
37
46
|
end
|
38
47
|
|
@@ -45,7 +54,7 @@ module JiraSync
|
|
45
54
|
if response.code == 200
|
46
55
|
response.parsed_response
|
47
56
|
else
|
48
|
-
raise "no issue found for #{project_id}. response code was #{response.code}, url was #{url}"
|
57
|
+
raise FetchError(response.status, "no issue found for #{project_id}. response code was #{response.code}, url was #{url}")
|
49
58
|
end
|
50
59
|
end
|
51
60
|
|
@@ -20,6 +20,11 @@ module JiraSync
|
|
20
20
|
File.utime(DateTime.now.to_time, updateTime.to_time, file_path)
|
21
21
|
end
|
22
22
|
|
23
|
+
def state_exists?
|
24
|
+
file_path = "#{@path}/sync_state.json"
|
25
|
+
File.exist?(file_path)
|
26
|
+
end
|
27
|
+
|
23
28
|
def save_state(state)
|
24
29
|
json = JSON.pretty_generate(state)
|
25
30
|
file_path = "#{@path}/sync_state.json"
|
data/lib/jirasync/syncer.rb
CHANGED
@@ -3,7 +3,6 @@ module JiraSync
|
|
3
3
|
require 'json'
|
4
4
|
require 'date'
|
5
5
|
|
6
|
-
|
7
6
|
class Syncer
|
8
7
|
|
9
8
|
def initialize(client, repo, project_key)
|
@@ -14,7 +13,6 @@ module JiraSync
|
|
14
13
|
@repo = repo
|
15
14
|
end
|
16
15
|
|
17
|
-
|
18
16
|
# Fetches a number of tickets in parallel
|
19
17
|
# prints progress information to stderr
|
20
18
|
# and returns a list of tickets that
|
@@ -31,6 +29,12 @@ module JiraSync
|
|
31
29
|
else
|
32
30
|
STDERR.puts("Skipping ticket #{key} which has moved to #{issue_project_key}.")
|
33
31
|
end
|
32
|
+
|
33
|
+
rescue FetchError => e
|
34
|
+
if (e.status != 404)
|
35
|
+
STDERR.puts(e.to_s)
|
36
|
+
keys_with_errors.push(key)
|
37
|
+
end
|
34
38
|
rescue => e
|
35
39
|
STDERR.puts(e.to_s)
|
36
40
|
keys_with_errors.push(key)
|
@@ -55,9 +59,9 @@ module JiraSync
|
|
55
59
|
STDERR.puts("Fetching issues that have changes since #{since.to_s}")
|
56
60
|
issues = @client.changed_since(@project_key, since)['issues'].map { |issue| issue['key'] }
|
57
61
|
STDERR.puts("Updated Issues")
|
58
|
-
STDERR.puts(issues.join(", "))
|
62
|
+
STDERR.puts(issues.empty? ? "None" : issues.join(", "))
|
59
63
|
STDERR.puts("Issues with earlier errors")
|
60
|
-
STDERR.puts(state['errors'].join(", "))
|
64
|
+
STDERR.puts(state['errors'].empty? ? "None" : state['errors'].join(", "))
|
61
65
|
keys_with_errors = fetch(issues + state['errors'])
|
62
66
|
@repo.save_state({"time" => start_time, "errors" => keys_with_errors})
|
63
67
|
end
|
data/lib/jirasync/version.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: jirasync
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: '0.
|
4
|
+
version: '0.3'
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2015-03-
|
12
|
+
date: 2015-03-20 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: trollop
|
@@ -65,6 +65,7 @@ description: ! "jirasync synchronises tickets from a jira project to the local\n
|
|
65
65
|
printed JSON file."
|
66
66
|
email: ''
|
67
67
|
executables:
|
68
|
+
- jira-format-issues
|
68
69
|
- jira-sync
|
69
70
|
extensions: []
|
70
71
|
extra_rdoc_files: []
|
@@ -74,6 +75,7 @@ files:
|
|
74
75
|
- LICENSE
|
75
76
|
- README.md
|
76
77
|
- Rakefile
|
78
|
+
- bin/jira-format-issues
|
77
79
|
- bin/jira-sync
|
78
80
|
- jira-sync.gemspec
|
79
81
|
- jirasync.gemspec
|