github_issue_sync 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c91c0d547df03f932c337f11c0c0833fa4dac08b49a24e44aad5bf0ca90ad51b
4
+ data.tar.gz: 18c1e0fac2508f956e56d7be063d2a1c86cc6f73bc41623bce05f3232f09f0c6
5
+ SHA512:
6
+ metadata.gz: 631e90c3e2e0aaecd7277141977de31b9e31f6704f1eae6c8a5c3d97d2e9cd488e9f49152390d673ebcebb8663335ff94a194add9511ba05ad21f83c5852d951
7
+ data.tar.gz: 5ff7536007cff5d4d5c5ccf447533f8c0e57200dc1cdcdb25542c06099832fe65273d01a8f12d512a8182309abd5036e9628b688b49acbbb23b7ba40daeeaf63
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-05-11
9
+
10
+ ### Added
11
+ - `GithubIssueSync::IssueExporter` — fetch GitHub issues by label/state and write to CSV or stdout.
12
+ - `GithubIssueSync::IssueSyncer` — read a CSV and push edits (title, state, body, labels) back to GitHub; create new issues for blank-numbered rows.
13
+ - `GithubIssueSync::IssueRow` — value object that maps between GitHub API responses and CSV columns; extracts Type, Priority, Section and Element from structured markdown body tables.
14
+ - `github-issue-sync` CLI (Thor-based) with `version`, `export`, and `sync` commands.
15
+ - RSpec test suite with VCR cassettes; no real GitHub token required to run tests.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brian Colfer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # github_issue_sync
2
+
3
+ Export GitHub issues to CSV and sync edits back to GitHub.
4
+
5
+ `github_issue_sync` provides two classes:
6
+
7
+ - **`GithubIssueSync::IssueExporter`** — fetches issues from a GitHub repository
8
+ and writes them to a CSV file suitable for editing in Google Sheets.
9
+ - **`GithubIssueSync::IssueSyncer`** — reads that CSV back and pushes any changes
10
+ (title, state, body, labels) to GitHub, and creates new issues for rows that
11
+ have no issue number.
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem "github_issue_sync"
19
+ ```
20
+
21
+ Or install it directly:
22
+
23
+ ```sh
24
+ gem install github_issue_sync
25
+ ```
26
+
27
+ ## Requirements
28
+
29
+ - Ruby >= 3.1
30
+ - A [GitHub personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)
31
+ with `repo` scope (or `public_repo` for public repositories).
32
+
33
+ ## Usage
34
+
35
+ ### Export issues to CSV
36
+
37
+ ```ruby
38
+ require "github_issue_sync"
39
+
40
+ exporter = GithubIssueSync::IssueExporter.new(
41
+ repo: "owner/repo",
42
+ token: ENV["GITHUB_TOKEN"],
43
+ labels: ["qa-feedback"], # filter by one or more labels
44
+ state: "open" # "open", "closed", or "all"
45
+ )
46
+
47
+ # Write to a file
48
+ exporter.call(output_path: "tmp/issues.csv")
49
+
50
+ # Preview without writing (dry-run)
51
+ exporter.call(output_path: "tmp/issues.csv", dry_run: true)
52
+ ```
53
+
54
+ The CSV columns are:
55
+
56
+ | Column | Description |
57
+ |--------|-------------|
58
+ | GitHub Issue # | Issue number |
59
+ | State | `open` or `closed` |
60
+ | Title | Issue title |
61
+ | Type | Extracted from body table or label (e.g. `bug`, `ux`) |
62
+ | Priority | Extracted from body table or label (e.g. `High`, `Low`) |
63
+ | Section | Page / section from structured body table |
64
+ | Element / Feature | Element from structured body table |
65
+ | Description | Full issue body |
66
+ | Labels | Comma-separated label names |
67
+ | URL | Link to the issue on GitHub |
68
+
69
+ ### Sync CSV edits back to GitHub
70
+
71
+ ```ruby
72
+ require "github_issue_sync"
73
+
74
+ syncer = GithubIssueSync::IssueSyncer.new(
75
+ repo: "owner/repo",
76
+ token: ENV["GITHUB_TOKEN"]
77
+ )
78
+
79
+ # Apply changes
80
+ result = syncer.call(csv_path: "tmp/issues.csv")
81
+ puts "Updated: #{result[:updated]}, Created: #{result[:created]}"
82
+
83
+ # Preview without making any API calls (dry-run)
84
+ result = syncer.call(csv_path: "tmp/issues.csv", dry_run: true)
85
+ puts "Would update: #{result[:would_update]}, Would create: #{result[:would_create]}"
86
+ ```
87
+
88
+ The syncer:
89
+
90
+ - **Updates** existing issues (matched by `GitHub Issue #`) when the title,
91
+ state, body, or label set has changed.
92
+ - **Creates** new issues for rows where `GitHub Issue #` is blank.
93
+ - **Skips** rows that are identical to the current GitHub state.
94
+
95
+ ### Structured body format
96
+
97
+ `IssueExporter` recognises a markdown table format in issue bodies and extracts
98
+ the **Type**, **Priority**, **Section**, and **Element / Feature** fields:
99
+
100
+ ```markdown
101
+ | **Page / Section** | Dashboard |
102
+ | **Element / Feature** | Export button |
103
+ | **Type** | Bug |
104
+ | **Priority** | 🔴 Critical |
105
+ ```
106
+
107
+ If a field is not present in the table, the exporter falls back to scanning
108
+ label names (e.g. a `high-priority` label maps to `High`).
109
+
110
+ ## CLI
111
+
112
+ After installing the gem the `github-issue-sync` command is available.
113
+
114
+ ```sh
115
+ # Show version
116
+ github-issue-sync version
117
+
118
+ # Print help
119
+ github-issue-sync help
120
+ github-issue-sync help export
121
+ github-issue-sync help sync
122
+ ```
123
+
124
+ ### Export
125
+
126
+ ```sh
127
+ # Print CSV to stdout
128
+ export GITHUB_TOKEN=ghp_...
129
+ github-issue-sync export --repo owner/repo
130
+
131
+ # Write to a file
132
+ github-issue-sync export --repo owner/repo --output issues.csv
133
+
134
+ # Filter by state and labels
135
+ github-issue-sync export --repo owner/repo --state closed --labels bug enhancement
136
+ ```
137
+
138
+ ### Sync
139
+
140
+ ```sh
141
+ # Push edits from a CSV back to GitHub
142
+ github-issue-sync sync --repo owner/repo --input issues.csv
143
+ ```
144
+
145
+ The sync command prints a summary to stderr on completion:
146
+
147
+ ```
148
+ Done. Updated: 3, Created: 1
149
+ ```
150
+
151
+ ## Development
152
+
153
+ ```sh
154
+ git clone https://github.com/briancolfer/github_issue_sync_extract
155
+ cd github_issue_sync_extract
156
+ bundle install
157
+ bundle exec rspec
158
+ ```
159
+
160
+ Tests use [VCR](https://github.com/vcr/vcr) cassettes so no real GitHub token
161
+ is needed. To re-record cassettes against a live repository, delete the
162
+ relevant file under `spec/fixtures/vcr_cassettes/` and run the suite with a
163
+ valid `GITHUB_TOKEN` in your environment.
164
+
165
+ ## Contributing
166
+
167
+ Bug reports and pull requests are welcome on
168
+ [GitHub](https://github.com/briancolfer/github_issue_sync_extract).
169
+
170
+ ## License
171
+
172
+ The gem is available as open source under the terms of the [MIT License](LICENSE).
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "github_issue_sync"
5
+
6
+ GithubIssueSync::CLI.start(ARGV)
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require_relative "../github_issue_sync"
5
+
6
+ module GithubIssueSync
7
+ class CLI < Thor
8
+ # Let Thor's required-option and unknown-command errors exit non-zero.
9
+ def self.exit_on_failure?
10
+ true
11
+ end
12
+
13
+ # -----------------------------------------------------------------
14
+ # version
15
+ # -----------------------------------------------------------------
16
+ desc "version", "Print the gem version"
17
+ def version
18
+ puts GithubIssueSync::VERSION
19
+ end
20
+
21
+ # -----------------------------------------------------------------
22
+ # export
23
+ # -----------------------------------------------------------------
24
+ desc "export", "Export GitHub issues to CSV"
25
+ method_option :repo,
26
+ aliases: "-r", required: true,
27
+ desc: "Repository slug (owner/repo)"
28
+ method_option :state,
29
+ aliases: "-s", default: "open",
30
+ desc: "Issue state: open, closed, or all"
31
+ method_option :labels,
32
+ aliases: "-l", type: :array, default: ["qa-feedback"],
33
+ desc: "Label filters (space-separated)"
34
+ method_option :output,
35
+ aliases: "-o",
36
+ desc: "Output file path (omit to print CSV to stdout)"
37
+ method_option :format,
38
+ aliases: "-f", default: "csv",
39
+ desc: "Output format (currently only csv)"
40
+ def export
41
+ token = require_token!
42
+ exporter = IssueExporter.new(
43
+ repo: options[:repo],
44
+ token: token,
45
+ labels: options[:labels],
46
+ state: options[:state]
47
+ )
48
+
49
+ if (path = options[:output])
50
+ exporter.call(output_path: path, io: $stderr)
51
+ else
52
+ exporter.call(io: $stdout)
53
+ end
54
+ end
55
+
56
+ # -----------------------------------------------------------------
57
+ # sync
58
+ # -----------------------------------------------------------------
59
+ desc "sync", "Sync CSV edits back to GitHub issues"
60
+ method_option :repo,
61
+ aliases: "-r", required: true,
62
+ desc: "Repository slug (owner/repo)"
63
+ method_option :input,
64
+ aliases: "-i", required: true,
65
+ desc: "Path to the input CSV file"
66
+ method_option :format,
67
+ aliases: "-f", default: "csv",
68
+ desc: "Input format (currently only csv)"
69
+ def sync
70
+ token = require_token!
71
+ syncer = IssueSyncer.new(repo: options[:repo], token: token)
72
+ result = syncer.call(csv_path: options[:input])
73
+ $stderr.puts "Done. Updated: #{result[:updated]}, Created: #{result[:created]}"
74
+ end
75
+
76
+ private
77
+
78
+ def require_token!
79
+ token = ENV["GITHUB_TOKEN"]
80
+ return token if token && !token.empty?
81
+
82
+ abort(
83
+ "Error: GITHUB_TOKEN environment variable is not set.\n" \
84
+ "Hint: export GITHUB_TOKEN=<your_personal_access_token>"
85
+ )
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "octokit"
4
+ require "csv"
5
+ require "github_issue_sync/issue_row"
6
+
7
+ module GithubIssueSync
8
+ # Fetches GitHub issues matching the given filters and writes them to a CSV
9
+ # that QA engineers can open in Google Sheets.
10
+ #
11
+ # Usage:
12
+ # exporter = GithubIssueSync::IssueExporter.new(
13
+ # repo: "owner/repo",
14
+ # token: ENV["GITHUB_TOKEN"],
15
+ # labels: ["qa-feedback"],
16
+ # state: "open"
17
+ # )
18
+ # exporter.call(output_path: "tmp/gh-issues-export.csv")
19
+ # exporter.call(output_path: "tmp/gh-issues-export.csv", dry_run: true, io: $stdout)
20
+ class IssueExporter
21
+ PER_PAGE = 100
22
+
23
+ def initialize(repo:, token:, labels: [ "qa-feedback" ], state: "open")
24
+ @repo = repo
25
+ @token = token
26
+ @labels = labels
27
+ @state = state
28
+ end
29
+
30
+ # @param output_path [String, nil] Path to write the CSV file; nil writes CSV to io.
31
+ # @param dry_run [Boolean] When true, print preview to io instead of writing.
32
+ # @param io [IO] Output stream for dry-run / nil-path mode (default $stdout).
33
+ def call(output_path: nil, dry_run: false, io: $stdout)
34
+ issues = fetch_all_issues
35
+ rows = issues.map { |i| IssueRow.from_github(i) }
36
+
37
+ if dry_run
38
+ print_preview(rows, io)
39
+ elsif output_path
40
+ write_csv(rows, output_path)
41
+ io.puts "Exported #{rows.size} issue(s) to #{output_path}" if io.respond_to?(:puts)
42
+ else
43
+ io.print generate_csv(rows)
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def client
50
+ @client ||= Octokit::Client.new(access_token: @token)
51
+ end
52
+
53
+ # Paginate through all matching issues, filtering out pull requests.
54
+ def fetch_all_issues
55
+ issues = []
56
+ page = 1
57
+ options = {
58
+ labels: @labels.join(","),
59
+ state: @state,
60
+ per_page: PER_PAGE
61
+ }
62
+
63
+ loop do
64
+ batch = client.list_issues(@repo, options.merge(page: page))
65
+ break if batch.empty?
66
+
67
+ # GitHub's issue endpoint returns PRs too; skip them.
68
+ issues.concat(batch.reject { |i| i.respond_to?(:pull_request) && i.pull_request })
69
+ break if batch.size < PER_PAGE
70
+
71
+ page += 1
72
+ end
73
+
74
+ issues
75
+ end
76
+
77
+ def write_csv(rows, path)
78
+ CSV.open(path, "w") do |csv|
79
+ csv << IssueRow::COLUMNS
80
+ rows.each { |row| csv << row.values }
81
+ end
82
+ end
83
+
84
+ def generate_csv(rows)
85
+ CSV.generate do |csv|
86
+ csv << IssueRow::COLUMNS
87
+ rows.each { |row| csv << row.values }
88
+ end
89
+ end
90
+
91
+ def print_preview(rows, io)
92
+ io.puts IssueRow::COLUMNS.join(", ")
93
+ io.puts "-" * 80
94
+ rows.each do |row|
95
+ io.puts row.values.map { |v| v.to_s.gsub(/\s+/, " ")[0, 60] }.join(" | ")
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module GithubIssueSync
6
+ # Represents one GitHub issue as a flat hash keyed by CSV column name.
7
+ # Knows how to build itself from a Sawyer::Resource (GitHub API response)
8
+ # or from a CSV row hash, and how to emit its values as an ordered array
9
+ # suitable for CSV serialisation.
10
+ class IssueRow
11
+ COLUMNS = [
12
+ "GitHub Issue #",
13
+ "State",
14
+ "Title",
15
+ "Type",
16
+ "Priority",
17
+ "Section",
18
+ "Element / Feature",
19
+ "Description",
20
+ "Labels",
21
+ "URL"
22
+ ].freeze
23
+
24
+ # Type labels recognised by import-qa-issues (lowercase match).
25
+ TYPE_LABELS = %w[bug enhancement ux copy question security performance].freeze
26
+
27
+ # Priority labels → human-readable names.
28
+ PRIORITY_LABEL_MAP = {
29
+ "critical" => "Critical",
30
+ "high-priority" => "High",
31
+ "pre-launch" => "High", # legacy: pre-launch treated as High priority
32
+ "low-priority" => "Low"
33
+ }.freeze
34
+
35
+ # Emoji prefixes written by import-qa-issues build_body_v2.
36
+ PRIORITY_EMOJI_RE = /\A(?:🔴|🟠|🟡|🟢)\s*/
37
+
38
+ # ------------------------------------------------------------------
39
+ # Construction
40
+ # ------------------------------------------------------------------
41
+
42
+ # Build an IssueRow from a Sawyer::Resource returned by Octokit.
43
+ def self.from_github(issue)
44
+ label_names = Array(issue.labels).map(&:name)
45
+ body = issue.body.to_s
46
+
47
+ new(
48
+ "GitHub Issue #" => issue.number.to_s,
49
+ "State" => issue.state.to_s,
50
+ "Title" => issue.title.to_s,
51
+ "Type" => extract_type(body, label_names),
52
+ "Priority" => extract_priority(body, label_names),
53
+ "Section" => extract_table_field(body, "Page / Section"),
54
+ "Element / Feature" => extract_table_field(body, "Element / Feature"),
55
+ "Description" => body,
56
+ "Labels" => label_names.join(", "),
57
+ "URL" => issue.html_url.to_s
58
+ )
59
+ end
60
+
61
+ # Build an IssueRow from a CSV::Row or plain Hash keyed by column name.
62
+ def self.from_csv_row(row)
63
+ data = COLUMNS.each_with_object({}) do |col, h|
64
+ h[col] = row[col].to_s
65
+ end
66
+ new(data)
67
+ end
68
+
69
+ # ------------------------------------------------------------------
70
+ # Instance interface
71
+ # ------------------------------------------------------------------
72
+
73
+ def initialize(data)
74
+ @data = data
75
+ end
76
+
77
+ def [](key) = @data[key]
78
+ def values = COLUMNS.map { |c| @data[c] }
79
+ def to_h = @data.dup
80
+ def keys = @data.keys
81
+
82
+ # ------------------------------------------------------------------
83
+ private
84
+
85
+ # ------------------------------------------------------------------
86
+
87
+ # Parse a field value out of the markdown table generated by import-qa-issues.
88
+ # Looks for: | **Field Name** | Value |
89
+ def self.extract_table_field(body, field_name)
90
+ pattern = /\|\s*\*\*#{Regexp.escape(field_name)}\*\*\s*\|\s*(.+?)\s*\|/
91
+ body.match(pattern)&.captures&.first.to_s.strip
92
+ end
93
+ private_class_method :extract_table_field
94
+
95
+ # Try the structured body table first; fall back to scanning label names.
96
+ def self.extract_type(body, label_names)
97
+ from_table = extract_table_field(body, "Type")
98
+ return from_table unless from_table.empty?
99
+
100
+ matched = label_names.find { |l| TYPE_LABELS.include?(l.downcase) }
101
+ matched ? matched.capitalize : ""
102
+ end
103
+ private_class_method :extract_type
104
+
105
+ # Try the structured body table (strip emoji prefix); fall back to labels.
106
+ def self.extract_priority(body, label_names)
107
+ from_table = extract_table_field(body, "Priority")
108
+ unless from_table.empty?
109
+ # Strip emoji and anything after the em-dash: "🟠 High — pre-launch preferred" → "High"
110
+ cleaned = from_table.sub(PRIORITY_EMOJI_RE, "").split(/\s*[—-]/).first.to_s.strip
111
+ return cleaned unless cleaned.empty?
112
+ end
113
+
114
+ label_names.each do |l|
115
+ mapped = PRIORITY_LABEL_MAP[l.downcase]
116
+ return mapped if mapped
117
+ end
118
+ ""
119
+ end
120
+ private_class_method :extract_priority
121
+ end
122
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "octokit"
4
+ require "csv"
5
+ require "github_issue_sync/issue_row"
6
+
7
+ module GithubIssueSync
8
+ # Reads a QA CSV (produced by IssueExporter or the v2 template) and:
9
+ # • Updates existing GitHub issues whose title, state, body, or labels changed.
10
+ # • Creates new issues for rows where "GitHub Issue #" is blank.
11
+ # • Skips rows that are identical to the current GitHub state.
12
+ #
13
+ # Usage:
14
+ # syncer = GithubIssueSync::IssueSyncer.new(repo: "owner/repo", token: ENV["GITHUB_TOKEN"])
15
+ # result = syncer.call(csv_path: "tmp/gh-issues-export.csv")
16
+ # result = syncer.call(csv_path: "...", dry_run: true, io: $stdout)
17
+ class IssueSyncer
18
+ def initialize(repo:, token:)
19
+ @repo = repo
20
+ @token = token
21
+ end
22
+
23
+ # @param csv_path [String] Path to the CSV to sync.
24
+ # @param dry_run [Boolean] When true, print intended actions but make no API calls.
25
+ # @param io [IO] Output stream for dry-run / summary (default $stdout).
26
+ # @return [Hash] Counts: { updated:, created: } or { would_update:, would_create: }
27
+ def call(csv_path:, dry_run: false, io: $stdout)
28
+ rows = load_csv(csv_path)
29
+
30
+ # Split into existing (have a number) and new (no number).
31
+ existing_rows, new_rows = rows.partition { |r| r["GitHub Issue #"].to_s.strip != "" }
32
+
33
+ # Fetch current state of all referenced issues in one pass so we can diff.
34
+ live_issues = fetch_live_issues(existing_rows.map { |r| r["GitHub Issue #"].to_i })
35
+
36
+ updated = 0
37
+ created = 0
38
+ would_update = 0
39
+ would_create = 0
40
+
41
+ existing_rows.each do |row|
42
+ number = row["GitHub Issue #"].to_i
43
+ live = live_issues[number]
44
+ next unless live # issue not found on GitHub — skip silently
45
+
46
+ if changed?(row, live)
47
+ if dry_run
48
+ io.puts "DRY-RUN UPDATE ##{number}: #{row['Title']}"
49
+ would_update += 1
50
+ else
51
+ patch_issue(number, row)
52
+ updated += 1
53
+ end
54
+ end
55
+ # else: nothing changed — skip
56
+ end
57
+
58
+ new_rows.each do |row|
59
+ if dry_run
60
+ io.puts "DRY-RUN CREATE: #{row['Title']}"
61
+ would_create += 1
62
+ else
63
+ post_issue(row)
64
+ created += 1
65
+ end
66
+ end
67
+
68
+ if dry_run
69
+ { would_update: would_update, would_create: would_create }
70
+ else
71
+ { updated: updated, created: created }
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def client
78
+ @client ||= Octokit::Client.new(access_token: @token)
79
+ end
80
+
81
+ def load_csv(path)
82
+ CSV.read(path, headers: true).map do |csv_row|
83
+ IssueRow.from_csv_row(csv_row)
84
+ end
85
+ end
86
+
87
+ # Fetch each referenced issue individually (acceptable for small QA CSVs).
88
+ def fetch_live_issues(numbers)
89
+ numbers.each_with_object({}) do |number, hash|
90
+ issue = client.issue(@repo, number)
91
+ hash[number] = IssueRow.from_github(issue)
92
+ rescue Octokit::NotFound
93
+ # Issue deleted on GitHub — skip without raising.
94
+ end
95
+ end
96
+
97
+ # Detect meaningful changes: title, state, body (description), or label set.
98
+ def changed?(csv_row, live_row)
99
+ csv_row["Title"] != live_row["Title"] ||
100
+ csv_row["State"] != live_row["State"] ||
101
+ csv_row["Description"] != live_row["Description"] ||
102
+ normalize_labels(csv_row["Labels"]) != normalize_labels(live_row["Labels"])
103
+ end
104
+
105
+ def normalize_labels(label_string)
106
+ label_string.to_s.split(",").map(&:strip).sort
107
+ end
108
+
109
+ def patch_issue(number, row)
110
+ client.update_issue(@repo, number,
111
+ title: row["Title"],
112
+ state: row["State"],
113
+ body: row["Description"],
114
+ labels: normalize_labels(row["Labels"]))
115
+ end
116
+
117
+ def post_issue(row)
118
+ labels = normalize_labels(row["Labels"])
119
+ labels |= [ "qa-feedback" ] # always include the qa-feedback label
120
+
121
+ client.create_issue(@repo,
122
+ row["Title"],
123
+ row["Description"],
124
+ labels: labels)
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GithubIssueSync
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "github_issue_sync/version"
4
+ require_relative "github_issue_sync/issue_row"
5
+ require_relative "github_issue_sync/issue_exporter"
6
+ require_relative "github_issue_sync/issue_syncer"
7
+ require_relative "github_issue_sync/cli"
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: github_issue_sync
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Brian Colfer
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: csv
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: octokit
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '9.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '9.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: thor
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ description: |
55
+ github_issue_sync provides two classes — IssueExporter and IssueSyncer — that
56
+ let you download GitHub issues into a CSV (suitable for Google Sheets) and push
57
+ edits or new rows back to GitHub.
58
+ executables:
59
+ - github-issue-sync
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - CHANGELOG.md
64
+ - LICENSE
65
+ - README.md
66
+ - exe/github-issue-sync
67
+ - lib/github_issue_sync.rb
68
+ - lib/github_issue_sync/cli.rb
69
+ - lib/github_issue_sync/issue_exporter.rb
70
+ - lib/github_issue_sync/issue_row.rb
71
+ - lib/github_issue_sync/issue_syncer.rb
72
+ - lib/github_issue_sync/version.rb
73
+ homepage: https://github.com/briancolfer/github_issue_sync_extract
74
+ licenses:
75
+ - MIT
76
+ metadata:
77
+ source_code_uri: https://github.com/briancolfer/github_issue_sync_extract
78
+ changelog_uri: https://github.com/briancolfer/github_issue_sync_extract/blob/main/CHANGELOG.md
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '3.1'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 4.0.8
94
+ specification_version: 4
95
+ summary: Export GitHub issues to CSV and sync changes back to GitHub.
96
+ test_files: []