├── Rakefile ├── Gemfile ├── .github └── no-response.yml ├── export-pull-requests.gemspec ├── Changes ├── .gitignore ├── README.md └── bin └── epr /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | gemspec 5 | -------------------------------------------------------------------------------- /.github/no-response.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-no-response - https://github.com/probot/no-response 2 | 3 | daysUntilClose: 7 4 | responseRequiredLabel: more info 5 | closeComment: > 6 | Closing due to lack of feedback. Feel free to reopen with additional info. 7 | -------------------------------------------------------------------------------- /export-pull-requests.gemspec: -------------------------------------------------------------------------------- 1 | require "date" 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "export-pull-requests" 5 | s.version = "0.4.0" 6 | s.date = Date.today 7 | s.summary = "Export pull requests and issues to a CSV file." 8 | s.description = "Program to export GitHub, GitLab, or Bitbucket pull requests/merge requests and issues to CSV a file." 9 | s.authors = ["Skye Shaw"] 10 | s.email = "skye.shaw@gmail.com" 11 | s.executables << "epr" 12 | s.extra_rdoc_files = %w[README.md Changes] 13 | s.homepage = "https://github.com/sshaw/export-pull-requests" 14 | s.license = "MIT" 15 | s.add_dependency "github_api", "~> 0.16" 16 | s.add_dependency "gitlab", "~> 4.0" 17 | # 1.3 is needed for Ruby >= v3 18 | # As of epr v4, Bitbucket API client supports < 1 19 | s.add_dependency "faraday", ">= 1.3", "< 2" 20 | s.add_development_dependency "rake", ">= 12.3.3" 21 | s.post_install_message = "Use the `epr' command to export your pull requests." 22 | end 23 | -------------------------------------------------------------------------------- /Changes: -------------------------------------------------------------------------------- 1 | v0.4.0 2022-02-10 2 | -------------------- 3 | Enhancements: 4 | * Support for Ruby versions >= 3 5 | 6 | Changes: 7 | * Bitbucket API client is no longer bundled see issue #26 for more info. 8 | 9 | 10 | v0.3.6 2021-04-28 11 | -------------------- 12 | Bug fixes: 13 | * Require Ruby < 3 (#25) 14 | 15 | v0.3.5 2021-03-16 16 | -------------------- 17 | Enhancements: 18 | * Add Merged column containing PR merge time 19 | * Exit with failure if attempting to export GitHub milestones for PRs 20 | 21 | v0.3.4 2021-02-12 22 | -------------------- 23 | Bug Fixes: 24 | * Bitbucket username not exported for PRs under 2.0 API (#20) 25 | 26 | v0.3.3 2019-05-01 27 | -------------------- 28 | Bug Fixes: 29 | * GitHub PR only exports were output as an "issue" instead of "PR" 30 | 31 | v0.3.2 2019-02-09 32 | -------------------- 33 | Bug Fixes: 34 | * Don't supply assignee or milestone for GitHub if nil 35 | 36 | -------------------- 37 | v0.3.1 2019-01-28 38 | -------------------- 39 | Enhancements: 40 | * Add support for filtering on milestones and labels to GitLab 41 | 42 | -------------------- 43 | v0.3.0 2019-01-10 44 | -------------------- 45 | Enhancements: 46 | * Add support for filtering on milestones and labels (GitHub only) 47 | * Add support for filtering on assignees (GitHub only, thanks Caroline Boyden) 48 | 49 | -------------------- 50 | v0.2.2 2018-11-30 51 | -------------------- 52 | Enhancements: 53 | * Add support for exporting issue body (GitHub only) 54 | 55 | -------------------- 56 | v0.2.1 2018-06-05 57 | -------------------- 58 | Enhancements: 59 | * Add support for custom endpoints 60 | 61 | -------------------- 62 | v0.2.0 2017-09-21 63 | -------------------- 64 | Enhancements: 65 | * Add support for issues 66 | 67 | Changes: 68 | * Always show Repository column (was excluded if only 1 repo was given) 69 | 70 | -------------------- 71 | v0.1.1 2017-07-23 72 | -------------------- 73 | Enhancements: 74 | * Add support for Bitbucket 75 | 76 | -------------------- 77 | v0.1.0 2017-07-23 78 | -------------------- 79 | Enhancements: 80 | * Add support for GitLab 81 | 82 | Changes: 83 | * Do not require a token 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/ruby,emacs 3 | 4 | ### Emacs ### 5 | # -*- mode: gitignore; -*- 6 | *~ 7 | \#*\# 8 | /.emacs.desktop 9 | /.emacs.desktop.lock 10 | *.elc 11 | auto-save-list 12 | tramp 13 | .\#* 14 | 15 | # Org-mode 16 | .org-id-locations 17 | *_archive 18 | 19 | # flymake-mode 20 | *_flymake.* 21 | 22 | # eshell files 23 | /eshell/history 24 | /eshell/lastdir 25 | 26 | # elpa packages 27 | /elpa/ 28 | 29 | # reftex files 30 | *.rel 31 | 32 | # AUCTeX auto folder 33 | /auto/ 34 | 35 | # cask packages 36 | .cask/ 37 | dist/ 38 | 39 | # Flycheck 40 | flycheck_*.el 41 | 42 | # server auth directory 43 | /server/ 44 | 45 | # projectiles files 46 | .projectile 47 | 48 | # directory configuration 49 | .dir-locals.el 50 | 51 | ### Ruby ### 52 | *.gem 53 | *.rbc 54 | /.config 55 | /coverage/ 56 | /InstalledFiles 57 | /pkg/ 58 | /spec/reports/ 59 | /spec/examples.txt 60 | /test/tmp/ 61 | /test/version_tmp/ 62 | /tmp/ 63 | 64 | # Used by dotenv library to load environment variables. 65 | # .env 66 | 67 | ## Specific to RubyMotion: 68 | .dat* 69 | .repl_history 70 | build/ 71 | *.bridgesupport 72 | build-iPhoneOS/ 73 | build-iPhoneSimulator/ 74 | 75 | ## Specific to RubyMotion (use of CocoaPods): 76 | # 77 | # We recommend against adding the Pods directory to your .gitignore. However 78 | # you should judge for yourself, the pros and cons are mentioned at: 79 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 80 | # 81 | # vendor/Pods/ 82 | 83 | ## Documentation cache and generated files: 84 | /.yardoc/ 85 | /_yardoc/ 86 | /doc/ 87 | /rdoc/ 88 | 89 | ## Environment normalization: 90 | /.bundle/ 91 | /vendor/bundle 92 | /lib/bundler/man/ 93 | 94 | # for a library or gem, you might want to ignore these files since the code is 95 | # intended to run in multiple environments; otherwise, check them in: 96 | Gemfile.lock 97 | # .ruby-version 98 | # .ruby-gemset 99 | 100 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 101 | .rvmrc 102 | 103 | # End of https://www.gitignore.io/api/ruby,emacs 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Export Pull Requests 2 | 3 | Export pull requests/merge requests and/or issues to a CSV file. 4 | 5 | Supports GitHub, GitLab, and Bitbucket. 6 | 7 | ## Installation 8 | 9 | [Ruby](https://www.ruby-lang.org/en/documentation/installation/) is required. 10 | With Ruby installed run: 11 | 12 | gem install export-pull-requests 13 | 14 | This installs the `epr` executable. 15 | 16 | ## Usage 17 | 18 | usage: epr [options] user/repo1 [user/repo2...] 19 | -b, --body Include the issue/pr body description in the output (GitHub only) 20 | -c, --creator=USER1,USER2,... Export PRs created by given username(s); prepend `!' to exclude user 21 | -e, --endpoint=URL Endpoint URL for 'enterprise', etc... repositories 22 | -m, --milestone=WHAT Export items assigned to the given milestone (GitHub/GitLab only) 23 | -a, --assignee=USER Export items assigned to the given user (GitHub/GitLab only) 24 | -l, --labels=LABEL(S) Export items with the given label(s) (GitHub/GitLab only) 25 | -h, --help Show this message 26 | -p, --provider=NAME Service provider: bitbucket, github, or gitlab; defaults to github 27 | -s, --state=STATE Export items in the given state, defaults to open 28 | -t, --token=TOKEN API token 29 | -x, --export=WHAT What to export: pr, issues, or all; defaults to all 30 | -v, --version epr version 31 | 32 | ### Config 33 | 34 | These can all be set by one of the below methods or [via the command line](#usage). 35 | 36 | #### Token 37 | 38 | The API token can be set by: 39 | 40 | * `EPR_TOKEN` environment variable 41 | * `epr.token` setting in `.gitconfig` (add via `git config --add epr.token `) 42 | * `github.oauth-token` setting in `.gitconfig` 43 | 44 | #### Default Service 45 | 46 | github is the default. You can set a new default via `EPR_SERVICE`. 47 | 48 | ### Examples 49 | 50 | Export open PRs and issues in `sshaw/git-link` and `sshaw/itunes_store_transporter`: 51 | 52 | epr sshaw/git-link sshaw/itunes_store_transporter > pr.csv 53 | 54 | Export open pull request not created by `sshaw` in `padrino/padrino-framework`: 55 | 56 | epr -x pr -c '!sshaw' padrino/padrino-framework > pr.csv 57 | 58 | Export open merge requests from a GitLab project: 59 | 60 | epr -x pr -p gitlab gitlab-org/gitlab-ce > pr.csv 61 | 62 | Export all issues from a GitLab project: 63 | 64 | epr -x issues -p gitlab gitlab-org/gitlab-ce > pr.csv 65 | 66 | ## Service Notes 67 | 68 | To connect to a custom/"Enterprise" installation of any of the supported services use the endpoint option (`-e`). 69 | 70 | The provided URL must point the API endpoint, not the user-facing site. For GitHub this is `http(s)://YOUR-SITE/api/v3`. 71 | 72 | ### Bitbucket 73 | 74 | **Due to [various issues with the Bitbucket gem](https://github.com/sshaw/export-pull-requests/issues/26) support for Bitbucket 75 | requires using a Ruby version < 3 and manually installing the Bitbucket library via `gem install bitbucket_rest_api`.** 76 | 77 | **Alternatively, on a Ruby version < 3 you can run: gem install export-pull-requests -v=0.3.7** 78 | 79 | You can use [app passwords](https://confluence.atlassian.com/bitbucket/app-passwords-828781300.html) for the API token. 80 | Just provide your token info in `bitbucket_username:app_password` format: 81 | 82 | epr -p bitbucket -t bitbucket_username:app_password user/repo1 83 | 84 | ### GitLab 85 | 86 | Authentication can be done via a [personal access token](https://gitlab.com/profile/personal_access_tokens). 87 | 88 | Enterprise editions of GitLab have an [issue export feature](https://docs.gitlab.com/ee/user/project/issues/csv_export.html). 89 | 90 | ## See Also 91 | 92 | - [Batch Labels](https://github.com/sshaw/batchlabels) - Add/remove labels in batches to/from GitHub issues and pull requests. 93 | 94 | ## Author 95 | 96 | Skye Shaw [skye.shaw AT gmail] 97 | 98 | ## License 99 | 100 | Released under the MIT License: www.opensource.org/licenses/MIT 101 | 102 | --- 103 | 104 | Made by [ScreenStaring](http://screenstaring.com) 105 | -------------------------------------------------------------------------------- /bin/epr: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "csv" 4 | require "optparse" 5 | require "time" 6 | require "logger" 7 | 8 | require "github_api" 9 | require "gitlab" 10 | 11 | VERSION = "0.4.0" 12 | SERVICES = %w[github gitlab bitbucket] 13 | GIT_CONFIGS = %w[epr.token github.oauth-token] 14 | 15 | TYPE_ISSUE = "Issue" 16 | TYPE_PR = "PR" 17 | 18 | EXPORT_ISSUES = "issues" 19 | EXPORT_PRS = "pr" 20 | 21 | DEFAULT_BODY_LENGTH = 2 ** 32 - 1 22 | 23 | def localtime(t) 24 | Time.parse(t).localtime.strftime("%x %X") 25 | end 26 | 27 | def parse_repos(repos) 28 | repos.map do |r| 29 | abort "invalid repository #{r}" unless r =~ %r{\A(\S+)/(\S+)\z} 30 | [ $1, $2 ] 31 | end 32 | end 33 | 34 | def skip_user?(user) 35 | $exclude_users.include?(user) || $include_users.any? && !$include_users.include?(user) 36 | end 37 | 38 | def lookup_token 39 | return ENV["EPR_TOKEN"] unless ENV["EPR_TOKEN"].to_s.strip.empty? 40 | 41 | begin 42 | GIT_CONFIGS.each do |setting| 43 | token = `git config #{setting}`.chomp 44 | return token unless token.empty? 45 | end 46 | rescue Errno::ENOENT 47 | # git not found, ignore 48 | end 49 | end 50 | 51 | def bitbucket(user, repo) 52 | # TODO: make sure no need to translate any states 53 | # https://developer.atlassian.com/bitbucket/api/2/reference/resource/repositories/%7Busername%7D/%7Brepo_slug%7D/pullrequests 54 | 55 | options = { :basic_auth => $token } 56 | options[:endpoint] = $endpoint if $endpoint 57 | 58 | $bitbucket ||= BitBucket.new(options) 59 | 60 | rows = [] 61 | no_user = "Anonymous" 62 | repo_name = "#{user}/#{repo}" 63 | 64 | pull_requests = lambda do 65 | page = 0 66 | 67 | loop do 68 | page += 1 69 | 70 | prs = $bitbucket.repos.pull_request.all(user, repo, :page => page, :state => $filter.upcase) 71 | prs["values"].each do |pr| 72 | next if pr.author && (skip_user?(pr.author.display_name) || skip_user?(pr.author.nickname)) 73 | 74 | rows << [ 75 | repo_name, 76 | TYPE_PR, 77 | pr.id, 78 | # With the 2.0 API is this check necessary? 79 | pr.author ? pr.author.display_name : no_user, 80 | pr.title, 81 | pr.state, 82 | localtime(pr.created_on), 83 | localtime(pr.updated_on), 84 | pr["links"].html.href 85 | ] 86 | end 87 | 88 | break unless prs["next"] 89 | end 90 | end 91 | 92 | issues = lambda do 93 | start = 0 94 | 95 | loop do 96 | issues = $bitbucket.issues.list_repo(user, repo, :start => start, :status => $filter) 97 | break unless issues.any? 98 | 99 | issues.each do |issue| 100 | next if issue["reported_by"] && skip_user?(issue["reported_by"]["username"]) 101 | 102 | rows << [ 103 | repo_name, 104 | TYPE_ISSUE, 105 | issue["local_id"], 106 | issue["reported_by"] ? issue["reported_by"]["username"] : no_user, 107 | issue["title"], 108 | issue["status"], 109 | localtime(issue["utc_created_on"]), 110 | localtime(issue["utc_last_updated"]), 111 | # Not in response 112 | sprintf("https://bitbucket.org/%s/issues/%s", repo_name, issue["local_id"]) 113 | ] 114 | end 115 | 116 | start += issues.size 117 | end 118 | end 119 | 120 | case $export 121 | when EXPORT_PRS 122 | pull_requests[] 123 | when EXPORT_ISSUES 124 | issues[] 125 | else 126 | pull_requests[] 127 | issues[] 128 | end 129 | 130 | rows 131 | end 132 | 133 | def github(user, repo) 134 | rows = [] 135 | method = $export == EXPORT_PRS ? :pull_requests : :issues 136 | 137 | options = { :oauth_token => $token, :auto_pagination => true } 138 | options[:endpoint] = $endpoint if $endpoint 139 | 140 | $gh ||= Github.new(options) 141 | 142 | options = { :user => user, :repo => repo, :state => $filter, :labels => $labels } 143 | options[:milestone] = $milestone if $milestone 144 | options[:assignee] = $assignee if $assignee 145 | 146 | $gh.public_send(method).list(options).each_page do |page| 147 | 148 | next if page.size.zero? # Needed for auto_pagination 149 | 150 | page.each do |item| 151 | # issues method will return issues and PRs 152 | next if $export == EXPORT_ISSUES && item.pull_request 153 | next if skip_user?(item.user.login) 154 | 155 | rows << [ 156 | "#{user}/#{repo}", 157 | # If we're only retrieving PRs then item.pull_request will be nil 158 | # It's only populated when retrieving both (issues method). 159 | item.pull_request || method == :pull_requests ? TYPE_PR : TYPE_ISSUE, 160 | item.number, 161 | item.user.login, 162 | item.title, 163 | item.state, 164 | localtime(item.created_at), 165 | localtime(item.updated_at) 166 | ] 167 | 168 | # GitHub issues API returns PRs but not their merged_at time. To get that we need to specifically export PRs 169 | if item.pull_request && $export != EXPORT_ISSUES 170 | rows[-1] << "(use `-x pr` option)" 171 | elsif $export != EXPORT_PRS 172 | rows[-1] << "N/A" 173 | else 174 | rows[-1] << (item.merged_at ? localtime(item.merged_at) : nil) 175 | end 176 | 177 | rows[-1] << item.html_url 178 | 179 | if $body 180 | body = item.body 181 | # -3 for "..." 182 | body = body.slice(0, DEFAULT_BODY_LENGTH - 3) << "..." if body.size > DEFAULT_BODY_LENGTH unless body == nil 183 | rows[-1].insert(4, body) 184 | end 185 | end 186 | end 187 | 188 | rows 189 | end 190 | 191 | def gitlab(user, repo) 192 | rows = [] 193 | 194 | case $export 195 | when EXPORT_PRS 196 | methods = [:merge_requests] 197 | when EXPORT_ISSUES 198 | methods = [:issues] 199 | else 200 | methods = [:merge_requests, :issues] 201 | end 202 | 203 | # Do we care about this differing in output? 204 | state = $filter == "open" ? "opened" : $filter 205 | options = { 206 | :milestone => $milestone, 207 | :labels => $labels, 208 | :state => state 209 | } 210 | 211 | # If assignee_id is nil an error is raised 212 | options[:assignee_id] = $assignee if $assignee 213 | 214 | $gitlab ||= Gitlab.client(:auth_token => $token, :endpoint => $endpoint || "https://gitlab.com/api/v4") 215 | methods.each do |method| 216 | $gitlab.public_send(method, "#{user}/#{repo}", options).auto_paginate do |item| 217 | next if skip_user?(item.author.username) 218 | 219 | rows << [ 220 | "#{user}/#{repo}", 221 | method == :issues ? TYPE_ISSUE : TYPE_PR, 222 | # Yes, it's called iid 223 | item.iid, 224 | item.author.username, 225 | item.title, 226 | item.state, 227 | localtime(item.created_at), 228 | localtime(item.updated_at) 229 | ] 230 | 231 | if method == :issues 232 | rows[-1] << "N/A" 233 | else 234 | rows[-1] << (item.merged_at ? localtime(item.merged_at) : nil) 235 | end 236 | 237 | rows[-1] << item.web_url 238 | end 239 | end 240 | 241 | rows 242 | end 243 | 244 | def export_repos(argv) 245 | rows = [] 246 | rows << %w[Repository Type # User Title State Created Updated Merged URL] 247 | rows[-1].insert(4, "Body") if $body 248 | 249 | repos = parse_repos(argv) 250 | repos.each do |user, repo| 251 | case $provider 252 | when "github" 253 | abort "milestone filtering can only be used with issues" if $milestone && $export == EXPORT_PRS 254 | rows.concat(github(user, repo)) 255 | when "gitlab" 256 | rows.concat(gitlab(user, repo)) 257 | when "bitbucket" 258 | begin 259 | require "bitbucket_rest_api" 260 | rescue LoadError => e 261 | # Could be an error due to gem version conflict 262 | abort e.message unless e.instance_of?(LoadError) 263 | abort(< e 362 | abort "Export failed: #{e}" 363 | end 364 | --------------------------------------------------------------------------------