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 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