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 +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.md +172 -0
- data/exe/github-issue-sync +6 -0
- data/lib/github_issue_sync/cli.rb +88 -0
- data/lib/github_issue_sync/issue_exporter.rb +99 -0
- data/lib/github_issue_sync/issue_row.rb +122 -0
- data/lib/github_issue_sync/issue_syncer.rb +127 -0
- data/lib/github_issue_sync/version.rb +5 -0
- data/lib/github_issue_sync.rb +7 -0
- metadata +96 -0
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,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,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: []
|