git-markdown 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.
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitMarkdown
4
+ module Api
5
+ class Client
6
+ def initialize(base_url:, token:)
7
+ @base_url = base_url
8
+ @token = token
9
+ end
10
+
11
+ def get(path, params = {})
12
+ uri = build_uri(path, params)
13
+ request = Net::HTTP::Get.new(uri)
14
+ set_headers(request)
15
+
16
+ response = http_request(uri, request)
17
+ Response.new(response)
18
+ end
19
+
20
+ private
21
+
22
+ def build_uri(path, params)
23
+ uri = URI.parse("#{@base_url}#{path}")
24
+ uri.query = URI.encode_www_form(params) unless params.empty?
25
+ uri
26
+ end
27
+
28
+ def set_headers(request)
29
+ request["Authorization"] = "Bearer #{@token}"
30
+ request["Accept"] = "application/vnd.github.v3+json"
31
+ request["User-Agent"] = "git-markdown/#{GitMarkdown::VERSION}"
32
+ end
33
+
34
+ def http_request(uri, request)
35
+ http = Net::HTTP.new(uri.host, uri.port)
36
+ http.use_ssl = uri.scheme == "https"
37
+ http.open_timeout = 10
38
+ http.read_timeout = 30
39
+
40
+ http.request(request)
41
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
42
+ raise ApiError, "Request timeout: #{e.message}"
43
+ rescue => e
44
+ raise ApiError, "Request failed: #{e.message}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitMarkdown
4
+ module Api
5
+ class Response
6
+ attr_reader :raw_response
7
+
8
+ def initialize(response)
9
+ @raw_response = response
10
+ end
11
+
12
+ def success?
13
+ @raw_response.is_a?(Net::HTTPSuccess)
14
+ end
15
+
16
+ def not_found?
17
+ @raw_response.is_a?(Net::HTTPNotFound)
18
+ end
19
+
20
+ def unauthorized?
21
+ @raw_response.is_a?(Net::HTTPUnauthorized)
22
+ end
23
+
24
+ def data
25
+ return nil unless @raw_response.body
26
+
27
+ JSON.parse(@raw_response.body)
28
+ rescue JSON::ParserError
29
+ nil
30
+ end
31
+
32
+ def error_message
33
+ return nil if success?
34
+
35
+ if data && data["message"]
36
+ data["message"]
37
+ else
38
+ "HTTP #{@raw_response.code}: #{@raw_response.message}"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitMarkdown
4
+ class CLI < Thor
5
+ class_option :debug, type: :boolean, default: false, desc: "Enable verbose debug output"
6
+
7
+ desc "setup", "Configure git-markdown with your GitHub token"
8
+ def setup
9
+ puts "git-markdown setup"
10
+ puts "=================="
11
+ puts
12
+ puts "This will store your GitHub token in ~/.config/git-markdown/credentials"
13
+ puts
14
+ puts "To create a token, visit:"
15
+ puts " GitHub: https://github.com/settings/personal-access-tokens"
16
+ puts " (For GitLab: https://gitlab.com/-/profile/personal_access_tokens)"
17
+ puts
18
+ puts "Required permissions/scopes:"
19
+ puts " - repo (for private repositories)"
20
+ puts " - read:org (for organization repositories)"
21
+ puts " - read:discussion (for PR discussions)"
22
+ puts
23
+ print "Enter your GitHub Personal Access Token: "
24
+ token = $stdin.noecho(&:gets).chomp
25
+ puts
26
+
27
+ if token.nil? || token.strip.empty?
28
+ puts "Error: Token cannot be empty"
29
+ exit 1
30
+ end
31
+
32
+ config = Configuration.new
33
+ config.save_credentials!(token)
34
+ config.save!
35
+
36
+ puts "✓ Credentials saved successfully"
37
+ puts "✓ Configuration saved to #{config.config_file}"
38
+ rescue => e
39
+ puts "Error: #{e.message}"
40
+ exit 1
41
+ end
42
+
43
+ desc "pr PR_IDENTIFIER", "Fetch a pull request and convert to Markdown"
44
+ option :output, type: :string, desc: "Output directory or file path"
45
+ option :stdout, type: :boolean, default: false, desc: "Output to stdout instead of file", lazy_default: true
46
+ option :status, type: :string, default: "unresolved", enum: %w[unresolved resolved all],
47
+ desc: "Filter comments by status"
48
+ def pr(identifier)
49
+ config = Configuration.load
50
+ owner, repo, number = parse_identifier(identifier)
51
+
52
+ debug_log(options[:debug], "Fetching PR #{owner}/#{repo}##{number}")
53
+ debug_log(options[:debug], "API URL: #{config.api_url}")
54
+
55
+ provider = create_provider(config)
56
+
57
+ puts "Fetching pull request..." unless options[:stdout]
58
+ pull_request = provider.fetch_pull_request(owner, repo, number)
59
+ debug_log(options[:debug], "PR title: #{pull_request.title}")
60
+
61
+ puts "Fetching comments..." unless options[:stdout]
62
+ comments = provider.fetch_comments(owner, repo, number)
63
+ debug_log(options[:debug], "Found #{comments.length} general comments")
64
+
65
+ puts "Fetching reviews..." unless options[:stdout]
66
+ reviews = provider.fetch_reviews(owner, repo, number)
67
+ debug_log(options[:debug], "Found #{reviews.length} reviews")
68
+
69
+ generator = Markdown::Generator.new(
70
+ pull_request,
71
+ comments,
72
+ reviews,
73
+ status_filter: options[:status].to_sym
74
+ )
75
+
76
+ markdown = generator.generate
77
+
78
+ if options[:stdout]
79
+ puts markdown
80
+ else
81
+ output_path = determine_output_path(options[:output], generator.filename)
82
+ File.write(output_path, markdown)
83
+ puts "✓ Saved to #{output_path}"
84
+ end
85
+ rescue AuthenticationError => e
86
+ puts "Authentication error: #{e.message}"
87
+ exit 1
88
+ rescue NotFoundError => e
89
+ puts "Not found: #{e.message}"
90
+ exit 1
91
+ rescue ApiError => e
92
+ puts "API error: #{e.message}"
93
+ debug_log(options[:debug], e.backtrace.join("\n"))
94
+ exit 1
95
+ rescue => e
96
+ puts "Error: #{e.message}"
97
+ debug_log(options[:debug], e.backtrace.join("\n"))
98
+ exit 1
99
+ end
100
+
101
+ desc "version", "Show version"
102
+ def version
103
+ puts GitMarkdown::VERSION
104
+ end
105
+
106
+ default_task :help
107
+
108
+ private
109
+
110
+ def parse_identifier(identifier)
111
+ if identifier.include?("#")
112
+ parts = identifier.split("#")
113
+ repo_parts = parts[0].split("/")
114
+ raise Error, "Invalid format. Use: owner/repo#123 or just 123" unless repo_parts.length == 2
115
+
116
+ [repo_parts[0], repo_parts[1], parts[1].to_i]
117
+
118
+ else
119
+ remote = RemoteParser.from_git_remote
120
+ if remote.nil? || !remote.valid?
121
+ raise Error, "Cannot detect repository from git remote. Use owner/repo#123 format."
122
+ end
123
+
124
+ [remote.owner, remote.repo, identifier.to_i]
125
+ end
126
+ end
127
+
128
+ def create_provider(config)
129
+ case config.provider
130
+ when :github
131
+ Providers::GitHub.new(config)
132
+ else
133
+ raise Error, "Unsupported provider: #{config.provider}"
134
+ end
135
+ end
136
+
137
+ def determine_output_path(output_option, filename)
138
+ if output_option.nil?
139
+ File.join(Dir.pwd, filename)
140
+ elsif File.directory?(output_option)
141
+ File.join(output_option, filename)
142
+ else
143
+ output_option
144
+ end
145
+ end
146
+
147
+ def debug_log(debug, message)
148
+ return unless debug
149
+
150
+ warn "[DEBUG] #{message}"
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module GitMarkdown
6
+ class Configuration
7
+ XDG_CONFIG_HOME = ENV.fetch("XDG_CONFIG_HOME", File.expand_path("~/.config"))
8
+ DEFAULT_PROVIDER = :github
9
+
10
+ attr_accessor :token, :provider, :api_url, :output_dir, :default_status
11
+
12
+ def initialize
13
+ @provider = DEFAULT_PROVIDER
14
+ @output_dir = Dir.pwd
15
+ @default_status = :unresolved
16
+ @api_url = nil
17
+ end
18
+
19
+ def self.load
20
+ new.load!
21
+ end
22
+
23
+ def load!
24
+ load_from_file if config_file_exist?
25
+ resolve_credentials
26
+ resolve_api_url if api_url.nil?
27
+ self
28
+ end
29
+
30
+ def config_dir
31
+ File.join(XDG_CONFIG_HOME, "git-markdown")
32
+ end
33
+
34
+ def config_file
35
+ File.join(config_dir, "config.yml")
36
+ end
37
+
38
+ def credentials_file
39
+ File.join(config_dir, "credentials")
40
+ end
41
+
42
+ def save!
43
+ FileUtils.mkdir_p(config_dir)
44
+ File.write(config_file, config_to_yaml)
45
+ FileUtils.chmod(0o700, config_dir)
46
+ end
47
+
48
+ def save_credentials!(token_value)
49
+ FileUtils.mkdir_p(config_dir)
50
+ File.write(credentials_file, token_value)
51
+ FileUtils.chmod(0o600, credentials_file)
52
+ @token = token_value
53
+ end
54
+
55
+ private
56
+
57
+ def config_file_exist?
58
+ File.exist?(config_file)
59
+ end
60
+
61
+ def load_from_file
62
+ require "yaml"
63
+ config = YAML.safe_load_file(
64
+ config_file,
65
+ symbolize_names: true,
66
+ permitted_classes: [Symbol]
67
+ )
68
+ @provider = config[:provider] if config[:provider]
69
+ @api_url = config[:api_url] if config[:api_url]
70
+ @output_dir = config[:output_dir] if config[:output_dir]
71
+ @default_status = config[:default_status].to_sym if config[:default_status]
72
+ end
73
+
74
+ def resolve_credentials
75
+ @token ||= Credentials.resolve(debug: false)
76
+ end
77
+
78
+ def resolve_api_url
79
+ @api_url = ENV.fetch("GITHUB_API_URL") do
80
+ (@provider == :github) ? "https://api.github.com" : nil
81
+ end
82
+ end
83
+
84
+ def config_to_yaml
85
+ {
86
+ provider: @provider,
87
+ api_url: @api_url,
88
+ output_dir: @output_dir,
89
+ default_status: @default_status
90
+ }.to_yaml
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitMarkdown
4
+ class Credentials
5
+ class << self
6
+ def resolve(debug: false)
7
+ token = from_env || from_git_credential || from_gh_cli || from_file
8
+
9
+ raise AuthenticationError, "No GitHub token found. Run `git-markdown setup` to configure." if token.nil?
10
+
11
+ token.strip
12
+ rescue => e
13
+ raise AuthenticationError, "Failed to resolve credentials: #{e.message}"
14
+ end
15
+
16
+ def from_env
17
+ ENV["GITHUB_TOKEN"] || ENV["GH_TOKEN"]
18
+ end
19
+
20
+ def from_git_credential
21
+ host = "github.com"
22
+ protocol = "https"
23
+
24
+ input = "protocol=#{protocol}\nhost=#{host}\n"
25
+ output = nil
26
+ env = {
27
+ "GIT_TERMINAL_PROMPT" => "0",
28
+ "GIT_ASKPASS" => "/bin/false",
29
+ "SSH_ASKPASS" => "/bin/false"
30
+ }
31
+
32
+ IO.popen(env, "git credential fill 2>/dev/null", "r+") do |io|
33
+ io.write(input)
34
+ io.close_write
35
+ output = io.read
36
+ end
37
+
38
+ return nil unless $?.success?
39
+
40
+ output.each_line do |line|
41
+ key, value = line.chomp.split("=", 2)
42
+ return value if key == "password"
43
+ end
44
+
45
+ nil
46
+ rescue
47
+ nil
48
+ end
49
+
50
+ def from_gh_cli
51
+ output = `gh auth token 2>/dev/null`
52
+ output.strip if $?.success? && !output.strip.empty?
53
+ rescue
54
+ nil
55
+ end
56
+
57
+ def from_file
58
+ credentials_file = File.join(
59
+ Configuration::XDG_CONFIG_HOME,
60
+ "git-markdown",
61
+ "credentials"
62
+ )
63
+
64
+ return nil unless File.exist?(credentials_file)
65
+
66
+ File.read(credentials_file).strip
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitMarkdown
4
+ module Markdown
5
+ class Generator
6
+ def initialize(pull_request, comments, reviews, status_filter: :unresolved)
7
+ @pr = pull_request
8
+ @comments = comments
9
+ @reviews = reviews
10
+ @status_filter = status_filter
11
+ end
12
+
13
+ def generate
14
+ template = File.read(template_path)
15
+ erb = ERB.new(template, trim_mode: "-")
16
+ erb.result(binding)
17
+ end
18
+
19
+ def filename
20
+ title_slug = @pr.title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "")
21
+ "PR-#{@pr.number}-#{title_slug}.md"
22
+ end
23
+
24
+ private
25
+
26
+ def template_path
27
+ File.join(__dir__, "templates", "default.erb")
28
+ end
29
+
30
+ def format_date(date_string)
31
+ return nil unless date_string
32
+
33
+ Time.parse(date_string).strftime("%Y-%m-%d %H:%M UTC")
34
+ rescue
35
+ date_string
36
+ end
37
+
38
+ def filtered_inline_comments
39
+ @reviews.flat_map(&:comments).select do |comment|
40
+ include_comment?(comment)
41
+ end
42
+ end
43
+
44
+ def filtered_general_comments
45
+ @comments.select do |comment|
46
+ include_comment?(comment)
47
+ end
48
+ end
49
+
50
+ def include_comment?(comment)
51
+ case @status_filter
52
+ when :unresolved
53
+ !comment.body.include?("[resolved]") && !comment.body.include?("[done]")
54
+ when :resolved
55
+ comment.body.include?("[resolved]") || comment.body.include?("[done]")
56
+ else
57
+ true
58
+ end
59
+ end
60
+
61
+ def group_comments_by_file(comments)
62
+ comments.select(&:inline?).group_by(&:path)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,56 @@
1
+ # <%= @pr.title %>
2
+
3
+ **#<%= @pr.number %>** by @<%= @pr.author %> | <%= @pr.state.upcase %> | <%= format_date(@pr.created_at) %>
4
+
5
+ [View on GitHub](<%= @pr.html_url %>)
6
+
7
+ ---
8
+
9
+ <%= @pr.body %>
10
+
11
+ ---
12
+
13
+ <% inline_comments = filtered_inline_comments %>
14
+ <% if inline_comments.any? %>
15
+ ## Review Comments
16
+
17
+ <% group_comments_by_file(inline_comments).each do |file, comments| %>
18
+ ### <%= file %>
19
+
20
+ <% comments.sort_by(&:line).each do |comment| %>
21
+ **Line <%= comment.line %>** — @<%= comment.author %> (<%= format_date(comment.created_at) %>)
22
+
23
+ <%= comment.body %>
24
+
25
+ ---
26
+
27
+ <% end %>
28
+ <% end %>
29
+ <% end %>
30
+
31
+ <% general_comments = filtered_general_comments %>
32
+ <% if general_comments.any? %>
33
+ ## General Comments
34
+
35
+ <% general_comments.each do |comment| %>
36
+ ### @<%= comment.author %> (<%= format_date(comment.created_at) %>)
37
+
38
+ <%= comment.body %>
39
+
40
+ ---
41
+
42
+ <% end %>
43
+ <% end %>
44
+
45
+ <% if @reviews.any? { |r| r.body && !r.body.empty? } %>
46
+ ## Review Summaries
47
+
48
+ <% @reviews.select { |r| r.body && !r.body.empty? }.each do |review| %>
49
+ ### @<%= review.author %> — <%= review.state %>
50
+
51
+ <%= review.body %>
52
+
53
+ ---
54
+
55
+ <% end %>
56
+ <% end %>
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitMarkdown
4
+ module Models
5
+ class Comment
6
+ attr_reader :id, :body, :author, :path, :line, :html_url, :created_at, :updated_at, :in_reply_to_id
7
+
8
+ def initialize(attrs = {})
9
+ @id = attrs[:id]
10
+ @body = attrs[:body] || ""
11
+ @author = attrs[:author]
12
+ @path = attrs[:path]
13
+ @line = attrs[:line]
14
+ @html_url = attrs[:html_url]
15
+ @created_at = attrs[:created_at]
16
+ @updated_at = attrs[:updated_at]
17
+ @in_reply_to_id = attrs[:in_reply_to_id]
18
+ end
19
+
20
+ def self.from_api(data)
21
+ new(
22
+ id: data["id"],
23
+ body: data["body"],
24
+ author: data.dig("user", "login"),
25
+ path: data["path"],
26
+ line: data["line"] || data["original_line"],
27
+ html_url: data["html_url"],
28
+ created_at: data["created_at"],
29
+ updated_at: data["updated_at"],
30
+ in_reply_to_id: data["in_reply_to_id"]
31
+ )
32
+ end
33
+
34
+ def inline?
35
+ !@path.nil?
36
+ end
37
+
38
+ def reply?
39
+ !@in_reply_to_id.nil?
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitMarkdown
4
+ module Models
5
+ class PullRequest
6
+ attr_reader :number, :title, :body, :state, :author, :html_url, :created_at, :updated_at
7
+
8
+ def initialize(attrs = {})
9
+ @number = attrs[:number]
10
+ @title = attrs[:title]
11
+ @body = attrs[:body] || ""
12
+ @state = attrs[:state]
13
+ @author = attrs[:author]
14
+ @html_url = attrs[:html_url]
15
+ @created_at = attrs[:created_at]
16
+ @updated_at = attrs[:updated_at]
17
+ end
18
+
19
+ def self.from_api(data)
20
+ new(
21
+ number: data["number"],
22
+ title: data["title"],
23
+ body: data["body"],
24
+ state: data["state"],
25
+ author: data.dig("user", "login"),
26
+ html_url: data["html_url"],
27
+ created_at: data["created_at"],
28
+ updated_at: data["updated_at"]
29
+ )
30
+ end
31
+
32
+ def open?
33
+ @state == "open"
34
+ end
35
+
36
+ def closed?
37
+ @state == "closed"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitMarkdown
4
+ module Models
5
+ class Review
6
+ attr_reader :id, :state, :body, :author, :html_url, :submitted_at
7
+ attr_accessor :comments
8
+
9
+ def initialize(attrs = {})
10
+ @id = attrs[:id]
11
+ @state = attrs[:state]
12
+ @body = attrs[:body] || ""
13
+ @author = attrs[:author]
14
+ @html_url = attrs[:html_url]
15
+ @submitted_at = attrs[:submitted_at]
16
+ @comments = attrs[:comments] || []
17
+ end
18
+
19
+ def self.from_api(data)
20
+ new(
21
+ id: data["id"],
22
+ state: data["state"],
23
+ body: data["body"],
24
+ author: data.dig("user", "login"),
25
+ html_url: data["html_url"],
26
+ submitted_at: data["submitted_at"]
27
+ )
28
+ end
29
+
30
+ def approved?
31
+ @state == "APPROVED"
32
+ end
33
+
34
+ def changes_requested?
35
+ @state == "CHANGES_REQUESTED"
36
+ end
37
+
38
+ def commented?
39
+ @state == "COMMENTED"
40
+ end
41
+
42
+ def dismissed?
43
+ @state == "DISMISSED"
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitMarkdown
4
+ module Providers
5
+ class Base
6
+ attr_reader :config
7
+
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+
12
+ def fetch_pull_request(owner, repo, number)
13
+ raise NotImplementedError
14
+ end
15
+
16
+ def fetch_comments(owner, repo, number)
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def fetch_reviews(owner, repo, number)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ def fetch_review_comments(owner, repo, review_id)
25
+ raise NotImplementedError
26
+ end
27
+ end
28
+ end
29
+ end