sync_issues 0.5.0 → 0.6.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: e6ec2a1e421c8518f79623a00467957940783a15
4
- data.tar.gz: 36447ea778835cca858463eeeec3e633b03c1ed0
3
+ metadata.gz: 44337f4aa3175ff1ba7e15f4583309ce034a251a
4
+ data.tar.gz: 29027fe887bb51d2412cf2fc9c4bba32d482b7db
5
5
  SHA512:
6
- metadata.gz: fd6ca399baa4a34133cca84e434b129b07b9c9b9f478ae5d6f9b3d8c9a929a2267864a51625375471369c67dbdb0145d299f5f6a7f22d98ab041153894e2c562
7
- data.tar.gz: 905384ce86b6e966b01b474750705d130d9379be3f01ef910e3567f964e390defeba6897f89e69225b0466fa37645e1200a7f75bcbc51106e892c9f1ec2db50a
6
+ metadata.gz: 9049e78d7fc5c02cfe7783d2f7676d3049218d627adf02dea2696da59b93f332277936d879b5b84cd78b614c54485a28ed02060ebdca11e33dd0923a104396d7
7
+ data.tar.gz: 120b8502dcb8ec42c1679ad735e0f80f2b2964ed4a491120ed7fba6cd3474e89b7fe006e618e228d28cb22496b5ea148a9472c3e5a725bfdbd21d4f23c3ccb71
data/README.md CHANGED
@@ -65,6 +65,36 @@ The frontmatter of an issue file can contain the following attributes:
65
65
  * __assignee__: (optional) Assign or reassign the issue to the github username
66
66
  specified. Existing assignee will not be removed on sync if the field is not
67
67
  provided.
68
- * __label__: (optional) Set the labels of the issue to this comma-separated
69
- string of issues. Existing labels will not be cleared on sync when the field
70
- is not provided.
68
+ * __labels__: (optional) When provided and the issue does not have any labels
69
+ this list of labels will be added to the issue. Labels will be dynamically
70
+ created with the default grey color if they don't already exist on the
71
+ repository. If you desire to reset the labels for each issue, run
72
+ ``sync_issues`` with the ``--reset-labels`` flag.
73
+
74
+ ## Synchronizing Labels
75
+
76
+ A path to a yaml file can be provided to synchronize labels on a
77
+ repository. This path is provided like so:
78
+
79
+ sync_issues --labels yaml_file.yml task_directory org/repo
80
+
81
+ The yaml file can contain two attributes:
82
+
83
+ * __keep_existing__: (optional) When ``false`` (default ``true``) all labels
84
+ that aren't in ``labels`` will be deleted. Set to ``false`` and provide no
85
+ labels to clear all labels.
86
+ * __labels__: (optional) A mapping of label names to their 6-character
87
+ hexidecimal color code without a `#` prefix. Color codes that contain only
88
+ numeric digits, e.g., `000000`, must be contained within quotes.
89
+
90
+ ### Example labels yaml file
91
+
92
+ ```yaml
93
+ keep_existing: false
94
+ labels:
95
+ in progress: bfe5bf
96
+ merged: "009800"
97
+ unstarted: fef2c0
98
+ waiting on developer: f7c6c7
99
+ waiting on reviewer: fad8c7
100
+ ```
@@ -18,7 +18,10 @@ module SyncIssues
18
18
  Options:
19
19
  -h --help Output this help information.
20
20
  -u --update Only update existing issues.
21
+ --labels FILE A yaml file listing labels that are to be available.
21
22
  --no-assignees Do not synchronize assignees.
23
+ --no-labels Do not synchronize labels.
24
+ --reset-labels Reset labels on a per-issue basis.
22
25
  --version Output the sync_issues version (#{VERSION}).
23
26
  DOC
24
27
 
@@ -46,9 +49,19 @@ module SyncIssues
46
49
 
47
50
  def handle_args(options)
48
51
  SyncIssues.synchronizer(options['DIRECTORY'], options['REPOSITORY'],
52
+ label_yaml: read_file(options['--labels']),
53
+ reset_labels: options['--reset-labels'],
49
54
  sync_assignees: !options['--no-assignees'],
55
+ sync_labels: !options['--no-labels'],
50
56
  update_only: options['--update']).run
51
57
  @exit_status
52
58
  end
59
+
60
+ def read_file(filename)
61
+ return nil if filename.nil?
62
+ File.read(filename)
63
+ rescue Errno::ENOENT
64
+ raise Error, "not found: #{filename}"
65
+ end
53
66
  end
54
67
  end
@@ -3,15 +3,16 @@ require_relative 'error'
3
3
  module SyncIssues
4
4
  # Comparison represents differences between Issues (local and GitHub)
5
5
  class Comparison
6
- attr_reader :assignee, :changed, :content, :title
6
+ attr_reader :assignee, :changed, :content, :labels, :title
7
7
 
8
- def initialize(issue, github_issue, sync_assignee)
8
+ def initialize(issue, github_issue, reset_labels: false,
9
+ sync_assignee: true, sync_labels: true)
9
10
  @changed = []
10
11
  @assignee = github_issue.assignee && github_issue.assignee.login
11
12
  @content = github_issue.body
12
- @sync_assignee = sync_assignee
13
+ @labels = github_issue.labels.map { |label| label[:name] }
13
14
  @title = github_issue.title
14
- compare(issue)
15
+ compare(issue, reset_labels, sync_assignee, sync_labels)
15
16
  end
16
17
 
17
18
  def changed?
@@ -20,11 +21,15 @@ module SyncIssues
20
21
 
21
22
  private
22
23
 
23
- def compare(issue)
24
- if @sync_assignee && issue.assignee != @assignee
24
+ def compare(issue, reset_labels, sync_assignee, sync_labels)
25
+ if sync_assignee && issue.assignee != @assignee
25
26
  @changed << 'assignee'
26
27
  @assignee = issue.assignee
27
28
  end
29
+ if sync_labels && update_label?(issue, reset_labels)
30
+ @changed << 'labels'
31
+ @labels = issue.labels
32
+ end
28
33
  unless issue.new_title.nil?
29
34
  @changed << 'title'
30
35
  @title = issue.new_title
@@ -38,5 +43,15 @@ module SyncIssues
38
43
  second.delete!("\r")
39
44
  first.gsub(/\[x\]/, '[ ]') == second.gsub(/\[x\]/, '[ ]')
40
45
  end
46
+
47
+ def labels_match?(new_labels)
48
+ # Label uniqueness is not case-sensitive.
49
+ new_labels.map(&:downcase) == @labels.map(&:downcase)
50
+ end
51
+
52
+ def update_label?(issue, reset_labels)
53
+ !issue.labels.nil? && (reset_labels && !labels_match?(issue.labels) ||
54
+ @labels.size == 0)
55
+ end
41
56
  end
42
57
  end
@@ -5,14 +5,17 @@ require 'safe_yaml/load'
5
5
  module SyncIssues
6
6
  # GitHub is responsible access to GitHub's API
7
7
  class GitHub
8
+ attr_reader :client
9
+
8
10
  def initialize
9
11
  @client = Octokit::Client.new access_token: token
10
12
  @client.auto_paginate = true
11
13
  end
12
14
 
13
- def create_issue(repository, issue, add_assignee)
15
+ def create_issue(repository, issue, add_assignee, add_labels)
14
16
  kwargs = {}
15
17
  kwargs[:assignee] = issue.assignee if add_assignee
18
+ kwargs[:labels] = issue.labels if add_labels
16
19
  @client.create_issue(repository.full_name, issue.title, issue.content,
17
20
  **kwargs)
18
21
  end
@@ -21,6 +24,10 @@ module SyncIssues
21
24
  @client.issues(repository.full_name, state: :all)
22
25
  end
23
26
 
27
+ def labels(repository)
28
+ @client.labels(repository.full_name)
29
+ end
30
+
24
31
  def repository(repository_name)
25
32
  @client.repository(repository_name)
26
33
  rescue Octokit::InvalidRepository => exc
@@ -29,9 +36,11 @@ module SyncIssues
29
36
  raise Error, 'repository not found'
30
37
  end
31
38
 
32
- def update_issue(repository, issue_number, title, content, assignee)
33
- @client.update_issue(repository.full_name, issue_number, title, content,
34
- assignee: assignee)
39
+ def update_issue(repository, issue_number, comparison)
40
+ @client.update_issue(repository.full_name, issue_number,
41
+ comparison.title, comparison.content,
42
+ assignee: comparison.assignee,
43
+ labels: comparison.labels)
35
44
  end
36
45
 
37
46
  private
@@ -6,22 +6,36 @@ module SyncIssues
6
6
  # new_title is only used when an issue should be renamed. Issues with
7
7
  # new_title set will never be created
8
8
  class Issue
9
- attr_reader :assignee, :content, :new_title, :title
9
+ attr_reader :assignee, :content, :labels, :new_title, :title
10
10
 
11
- def initialize(content, title:, assignee: nil, new_title: nil)
11
+ def initialize(content, title:, assignee: nil, labels: nil, new_title: nil)
12
12
  @assignee = verify_string 'assignee', assignee
13
13
  @content = content
14
- @title = verify_string 'title', title, allow_nil: false
14
+ @labels = verify_labels(labels)
15
15
  @new_title = verify_string 'new_title', new_title, allow_nil: true
16
+ @title = verify_string 'title', title, allow_nil: false
16
17
  end
17
18
 
18
19
  private
19
20
 
21
+ def verify_labels(labels)
22
+ return nil if labels.nil?
23
+ if labels.is_a?(String)
24
+ [verify_string('labels', labels, allow_nil: false)]
25
+ elsif !labels.is_a?(Array)
26
+ raise IssueError, "'labels' must be an Array or a String"
27
+ else
28
+ labels.each_with_index.map do |label, i|
29
+ verify_string("labels[#{i}]", label, allow_nil: false)
30
+ end
31
+ end
32
+ end
33
+
20
34
  def verify_string(field, value, allow_nil: true)
21
35
  if value.nil?
22
36
  raise IssueError, "'#{field}' must be provided" unless allow_nil
23
37
  elsif !value.is_a?(String)
24
- raise IssueError, "'#{field}' must be a string"
38
+ raise IssueError, "'#{field}' must be a String"
25
39
  else
26
40
  value.strip!
27
41
  raise IssueError, "'#{field}' must not be blank" if value == ''
@@ -0,0 +1,93 @@
1
+ require_relative 'error'
2
+ require 'safe_yaml/load'
3
+
4
+ module SyncIssues
5
+ # Synchronizer is responsible for the actual synchronization.
6
+ class LabelSync
7
+ attr_reader :do_work, :keep_existing, :labels
8
+
9
+ def initialize(github, file_yaml)
10
+ @github = github
11
+ @labels = @keep_existing = nil
12
+ @do_work = file_yaml.nil? ? false : parse_yaml(file_yaml)
13
+ end
14
+
15
+ def synchronize(repository)
16
+ return unless @do_work
17
+ existing = existing_labels(repository)
18
+ make_changes(existing, repository)
19
+ end
20
+
21
+ private
22
+
23
+ def add_labels(labels, repository)
24
+ labels.each do |label, color|
25
+ puts "\tadd label: #{label}"
26
+ @github.client.add_label(repository.full_name, label, color)
27
+ end
28
+ end
29
+
30
+ def delete_labels(labels, repository)
31
+ labels.each do |label, _|
32
+ if @keep_existing
33
+ puts "\tkeeping label: #{label}"
34
+ else
35
+ puts "\tdelete label: #{label}"
36
+ @github.client.delete_label!(repository.full_name, label)
37
+ end
38
+ end
39
+ end
40
+
41
+ def make_changes(existing, repository)
42
+ changes = { add: [], update: [] }
43
+ @labels.each do |label, color|
44
+ if existing.include?(label)
45
+ changes[:update] << [label, color] unless existing[label] == color
46
+ existing.delete(label)
47
+ else
48
+ changes[:add] << [label, color]
49
+ end
50
+ end
51
+
52
+ add_labels(changes[:add], repository)
53
+ update_labels(changes[:update], repository)
54
+ delete_labels(existing, repository)
55
+ rescue Octokit::UnprocessableEntity => exc
56
+ raise unless exc.errors.count == 1 && exc.errors[0][:resource] == 'Label'
57
+ error = exc.errors[0]
58
+ raise Error, "Label error: #{error[:code]} #{error[:field]}"
59
+ end
60
+
61
+ def existing_labels(repository)
62
+ Hash[@github.labels(repository).map do |label|
63
+ [label[:name], label[:color]]
64
+ end]
65
+ end
66
+
67
+ def parse_yaml(yaml)
68
+ data = SafeYAML.load(yaml)
69
+ return nil unless data
70
+ if data.include?('labels') && data['labels'].is_a?(Hash)
71
+ @labels = Hash[data['labels'].map do |label, color|
72
+ if color.is_a?(Integer)
73
+ raise Error, 'Label error: add quotes around numeric color values'
74
+ end
75
+ [label, color.to_s.downcase]
76
+ end]
77
+ else
78
+ @labels = {}
79
+ end
80
+ @keep_existing = data['keep_existing'] != false
81
+ @labels.size > 0 || !@keep_existing
82
+ rescue Psych::SyntaxError
83
+ raise ParseError, 'invalid label yaml file'
84
+ end
85
+
86
+ def update_labels(labels, repository)
87
+ labels.each do |label, color|
88
+ puts "\tupdate label: #{label}"
89
+ @github.client.update_label(repository.full_name, label, color: color)
90
+ end
91
+ end
92
+ end
93
+ end
@@ -1,23 +1,27 @@
1
1
  require_relative 'comparison'
2
2
  require_relative 'error'
3
+ require_relative 'label_sync'
3
4
  require_relative 'parser'
4
5
  require 'English'
5
6
 
6
7
  module SyncIssues
7
8
  # Synchronizer is responsible for the actual synchronization.
8
9
  class Synchronizer
9
- def initialize(directory, repository_names, sync_assignees: true,
10
- update_only: false)
10
+ def initialize(directory, repository_names, label_yaml: nil,
11
+ reset_labels: false, sync_assignees: true,
12
+ sync_labels: true, update_only: false)
11
13
  @github = SyncIssues.github
12
14
  @issues = issues(directory)
15
+ @label_sync = LabelSync.new(@github, label_yaml)
13
16
  @repositories = repositories(repository_names)
17
+ @reset_labels = reset_labels
14
18
  @sync_assignees = sync_assignees
19
+ @sync_labels = sync_labels
15
20
  @update_only = update_only
16
21
  end
17
22
 
18
23
  def run
19
24
  puts "Synchronize #{@issues.count} issue#{@issues.count == 1 ? '' : 's'}"
20
- @issues.each { |issue| puts " * #{issue.title}" }
21
25
  @repositories.each { |repository| synchronize(repository) }
22
26
  end
23
27
 
@@ -62,6 +66,7 @@ module SyncIssues
62
66
 
63
67
  def synchronize(repository)
64
68
  puts "Repository: #{repository.full_name}"
69
+ @label_sync.synchronize(repository) if @sync_labels
65
70
 
66
71
  existing_by_title = {}
67
72
  @github.issues(repository).each do |issue|
@@ -84,18 +89,20 @@ module SyncIssues
84
89
  puts "Skipping create issue: #{issue.title}"
85
90
  else
86
91
  puts "Adding issue: #{issue.title}"
87
- @github.create_issue(repository, issue, @sync_assignees)
92
+ @github.create_issue(repository, issue, @sync_assignees, @sync_labels)
88
93
  end
89
94
  end
90
95
 
91
96
  def update_issue(repository, issue, github_issue)
92
- comparison = Comparison.new(issue, github_issue, @sync_assignees)
97
+ comparison = Comparison.new(issue, github_issue,
98
+ reset_labels: @reset_labels,
99
+ sync_assignee: @sync_assignees,
100
+ sync_labels: @sync_labels)
93
101
  return unless comparison.changed?
94
102
 
95
103
  changed = comparison.changed.join(', ')
96
104
  puts "Updating #{changed} on ##{github_issue.number}"
97
- @github.update_issue(repository, github_issue.number, comparison.title,
98
- comparison.content, comparison.assignee)
105
+ @github.update_issue(repository, github_issue.number, comparison)
99
106
  end
100
107
  end
101
108
  end
@@ -1,4 +1,4 @@
1
1
  # SyncIssues
2
2
  module SyncIssues
3
- VERSION = '0.5.0'.freeze
3
+ VERSION = '0.6.0'.freeze
4
4
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sync_issues
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bryce Boe
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-03-10 00:00:00.000000000 Z
11
+ date: 2016-03-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: docopt
@@ -70,6 +70,7 @@ files:
70
70
  - lib/sync_issues/error.rb
71
71
  - lib/sync_issues/github.rb
72
72
  - lib/sync_issues/issue.rb
73
+ - lib/sync_issues/label_sync.rb
73
74
  - lib/sync_issues/parser.rb
74
75
  - lib/sync_issues/sync_issues_module.rb
75
76
  - lib/sync_issues/synchronizer.rb