jirasync 0.2 → 0.3

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -1,2 +1,143 @@
1
1
  # jira-sync
2
- A utility to synchronise jira projects to the local file system
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
@@ -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, opts[:project].chomp)
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
@@ -6,7 +6,7 @@ Gem::Specification.new do |s|
6
6
  an incremental update.
7
7
 
8
8
  Each ticket is stored in a simple, pretty printed JSON file.'
9
- s.version = '0.2'
9
+ s.version = '0.3'
10
10
  s.platform = Gem::Platform::RUBY
11
11
 
12
12
  s.files = ['bin/jira-sync']
@@ -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 "no issue found for #{jira_id}. response code was #{response.code}, url was #{url}"
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"
@@ -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
@@ -1,3 +1,3 @@
1
1
  module JiraSync
2
- VERSION = "0.0.2"
2
+ VERSION = "0.0.3"
3
3
  end
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.2'
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-19 00:00:00.000000000 Z
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