hoe-halostatue 2.1.1 → 3.0.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 +4 -4
- data/CHANGELOG.md +21 -0
- data/CODE_OF_CONDUCT.md +152 -116
- data/CONTRIBUTING.md +120 -31
- data/CONTRIBUTORS.md +15 -14
- data/LICENCE.md +33 -6
- data/Manifest.txt +8 -0
- data/README.md +303 -34
- data/Rakefile +66 -9
- data/SECURITY.md +6 -10
- data/lib/hoe/halostatue/checklist.rb +46 -0
- data/lib/hoe/halostatue/gemspec.rb +104 -0
- data/lib/hoe/halostatue/git.rb +81 -0
- data/lib/hoe/halostatue/markdown/linkify.rb +423 -0
- data/lib/hoe/halostatue/markdown.rb +152 -0
- data/lib/hoe/halostatue/version.rb +1 -1
- data/lib/hoe/halostatue.rb +96 -173
- data/licences/dco.txt +34 -0
- data/test/hoe/halostatue/markdown/test_linkify.rb +527 -0
- data/test/minitest_helper.rb +11 -0
- metadata +112 -29
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hoe::Halostatue::Gemspec
|
|
4
|
+
# Whether a fixed date should be used for reproducible gemspec values. This is ignored
|
|
5
|
+
# if `$SOURCE_DATE_EPOCH` is set. Acceptable values are:
|
|
6
|
+
#
|
|
7
|
+
# - `:default` or `true`: uses the RubyGems default source date epoch
|
|
8
|
+
# - `:current`: uses the date stored in the most recent gemspec file
|
|
9
|
+
# - `false`: sets the release date to the current date
|
|
10
|
+
# - An epoch value, either as an Integer or a String
|
|
11
|
+
#
|
|
12
|
+
# [default: `:default`]
|
|
13
|
+
attr_accessor :reproducible_gemspec
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
LINKS = /\[(?<name>.+?)\](?:\(.+?\)|\[.+?\])/ # :nodoc:
|
|
18
|
+
PERMITTED_CLASSES = [ # :nodoc:
|
|
19
|
+
Symbol, Time, Date, Gem::Dependency, Gem::Platform, Gem::Requirement,
|
|
20
|
+
Gem::Specification, Gem::Version, Gem::Version::Requirement
|
|
21
|
+
].freeze
|
|
22
|
+
PERMITTED_SYMBOLS = %i[development runtime].freeze # :nodoc:
|
|
23
|
+
|
|
24
|
+
private_constant :LINKS, :PERMITTED_CLASSES, :PERMITTED_SYMBOLS
|
|
25
|
+
|
|
26
|
+
def initialize_halostatue_gemspec
|
|
27
|
+
self.reproducible_gemspec = :default
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def define_halostatue_gemspec_tasks
|
|
31
|
+
gemspec = "#{spec.name}.gemspec"
|
|
32
|
+
|
|
33
|
+
with_config do
|
|
34
|
+
unless ".gemspec".match?(_1["exclude"])
|
|
35
|
+
warn "WARNING You should add .gemspec to your .hoerc exclude list"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
epoch = resolve_source_date_epoch
|
|
40
|
+
ENV["SOURCE_DATE_EPOCH"] = epoch&.to_s
|
|
41
|
+
|
|
42
|
+
file gemspec => %w[clobber Manifest.txt] + spec.files do
|
|
43
|
+
spec2 = resolve_gemspec
|
|
44
|
+
spec2.date = epoch if spec2.respond_to?(:date=)
|
|
45
|
+
|
|
46
|
+
clear_rubygem_signing(spec2)
|
|
47
|
+
clean_markdown_links(spec2)
|
|
48
|
+
|
|
49
|
+
File.write(gemspec, spec2.to_ruby)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
desc "Regenerate #{gemspec}"
|
|
53
|
+
task gemspec: gemspec
|
|
54
|
+
task default: gemspec
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def clear_rubygem_signing(spec)
|
|
58
|
+
spec.signing_key = spec.default_value(:signing_key)
|
|
59
|
+
spec.cert_chain = spec.default_value(:cert_chain)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def clean_markdown_links(spec)
|
|
63
|
+
spec.description = spec.description.gsub(LINKS, '\k<name>').gsub(/\r?\n/, " ")
|
|
64
|
+
spec.summary = spec.summary.gsub(LINKS, '\k<name>').gsub(/\r?\n/, " ")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resolve_source_date_epoch
|
|
68
|
+
epoch = ENV["SOURCE_DATE_EPOCH"]
|
|
69
|
+
epoch = nil if !epoch.nil? && epoch.strip.empty?
|
|
70
|
+
|
|
71
|
+
if epoch
|
|
72
|
+
Time.at(epoch.to_i).utc.freeze
|
|
73
|
+
elsif reproducible_gemspec == :default || reproducible_gemspec == true
|
|
74
|
+
Gem.source_date_epoch
|
|
75
|
+
elsif reproducible_gemspec == :current
|
|
76
|
+
Gem::Specification.load(gemspec)&.date&.freeze || Gem.source_date_epoch
|
|
77
|
+
elsif reproducible_gemspec.is_a?(String)
|
|
78
|
+
Time.at(reproducible_gemspec.to_i).utc.freeze
|
|
79
|
+
elsif reproducible_gemspec.is_a?(Integer)
|
|
80
|
+
Time.at(reproducible_gemspec).utc.freeze
|
|
81
|
+
elsif reproducible_gemspec == false
|
|
82
|
+
nil
|
|
83
|
+
else
|
|
84
|
+
raise ArgumentError,
|
|
85
|
+
"Invalid value for `reproducible_gemspec`: #{reproducible_gemspec.inspect}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def resolve_gemspec
|
|
90
|
+
YAML.safe_load(
|
|
91
|
+
YAML.safe_dump(
|
|
92
|
+
spec,
|
|
93
|
+
permitted_classes: PERMITTED_CLASSES,
|
|
94
|
+
permitted_symbols: PERMITTED_SYMBOLS,
|
|
95
|
+
aliases: true
|
|
96
|
+
),
|
|
97
|
+
permitted_classes: PERMITTED_CLASSES,
|
|
98
|
+
permitted_symbols: PERMITTED_SYMBOLS,
|
|
99
|
+
aliases: true
|
|
100
|
+
)
|
|
101
|
+
rescue
|
|
102
|
+
YAML.safe_load(YAML.dump(spec), PERMITTED_CLASSES, PERMITTED_SYMBOLS, true)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Hoe::Halostatue::Git
|
|
6
|
+
# What do you want at the front of your release tags?
|
|
7
|
+
# [default: `"v"`]
|
|
8
|
+
attr_accessor :git_release_tag_prefix
|
|
9
|
+
|
|
10
|
+
# Which remotes do you want to push tags, etc. to?
|
|
11
|
+
# [default: `%w[origin]`]
|
|
12
|
+
attr_accessor :git_remotes
|
|
13
|
+
|
|
14
|
+
# Should git tags be created on release? [default: `true`]
|
|
15
|
+
attr_accessor :git_tag_enabled
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def initialize_halostatue_git # :nodoc:
|
|
20
|
+
self.git_release_tag_prefix = "v"
|
|
21
|
+
self.git_remotes = %w[origin]
|
|
22
|
+
self.git_tag_enabled = true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def define_halostatue_git_tasks # :nodoc:
|
|
26
|
+
return unless __run_git("rev-parse", "--is-inside-work-tree") == "true"
|
|
27
|
+
|
|
28
|
+
desc "Update the manifest with Git's file list. Use Hoe's excludes."
|
|
29
|
+
task "git:manifest" do
|
|
30
|
+
with_config do |config, _|
|
|
31
|
+
files = __run_git("ls-files")
|
|
32
|
+
.split($/)
|
|
33
|
+
.grep_v(config["exclude"])
|
|
34
|
+
|
|
35
|
+
File.write "Manifest.txt", files.sort.join("\n") + "\n"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
desc "Create and push a TAG (default #{git_release_tag_prefix}#{version})."
|
|
40
|
+
task "git:tag" do
|
|
41
|
+
if git_tag_enabled
|
|
42
|
+
tag = ENV["TAG"]
|
|
43
|
+
ver = ENV["VERSION"] || version
|
|
44
|
+
pre = ENV["PRERELEASE"] || ENV["PRE"]
|
|
45
|
+
ver += ".#{pre}" if pre && !ver.ends_with?(pre)
|
|
46
|
+
tag ||= "#{git_release_tag_prefix}#{ver}"
|
|
47
|
+
|
|
48
|
+
git_tag_and_push tag
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
task :release_sanity do
|
|
53
|
+
unless __run_git("status", "--porcelain").empty?
|
|
54
|
+
abort "Won't release: Dirty index or untracked files present!"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
task release_to: "git:tag"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def __git(command, *params)
|
|
62
|
+
"git #{command.shellescape} #{params.compact.shelljoin}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def __run_git(command, *params)
|
|
66
|
+
`#{__git(command, *params)}`.strip.chomp
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def git_svn?
|
|
70
|
+
File.exist?(File.join(__run_git("rev-parse", "--show-toplevel"), ".git/svn"))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def git_tag_and_push tag
|
|
74
|
+
msg = "Tagging #{tag}."
|
|
75
|
+
|
|
76
|
+
flags = "-s" unless __run_git("config", "--get", "user.signingkey").empty?
|
|
77
|
+
|
|
78
|
+
sh __git("tag", flags, "-f", tag, "-m", msg)
|
|
79
|
+
git_remotes.each { |remote| sh __git("push", "-f", remote, "tag", tag) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "kramdown"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
class Hoe
|
|
7
|
+
module Halostatue
|
|
8
|
+
module Markdown
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class Hoe::Halostatue::Markdown::Linkify # :nodoc:
|
|
14
|
+
# Match GitHub username (@username) but not email addresses or Mastodon handles
|
|
15
|
+
# Basic pattern - additional validation done post-match
|
|
16
|
+
USERNAME_PATTERN = %r{
|
|
17
|
+
(?<![A-Za-z0-9]) # Not preceded by alphanumeric (prevents email match)
|
|
18
|
+
@ # Literal @ sign
|
|
19
|
+
([A-Za-z0-9-]{1,39}) # 1-39 chars of alphanumeric or hyphen
|
|
20
|
+
(?![-A-Za-z0-9@]) # Not followed by alphanumeric, hyphen, or @ (prevents Mastodon/email)
|
|
21
|
+
}x
|
|
22
|
+
|
|
23
|
+
def self.linkify_file(filename, ...)
|
|
24
|
+
new(...).linkify(File.read(filename))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.linkify(source, ...)
|
|
28
|
+
new(...).linkify(source)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.normalize_uri_prefixes(prefixes)
|
|
32
|
+
case prefixes
|
|
33
|
+
when true
|
|
34
|
+
{issue: "issue", pull: "pull request", discussion: "discussion"}
|
|
35
|
+
when false, nil
|
|
36
|
+
{}
|
|
37
|
+
else
|
|
38
|
+
extra = prefixes.keys - %i[issue pull discussion]
|
|
39
|
+
|
|
40
|
+
if extra.empty?
|
|
41
|
+
prefixes
|
|
42
|
+
else
|
|
43
|
+
raise ArgumentError, "Extra keys for markdown_linkify_uri_prefixes: #{extra.inspect}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(bug_tracker_uri: nil, style: :reference, uri_prefixes: {})
|
|
49
|
+
@bug_tracker_uri = bug_tracker_uri
|
|
50
|
+
@repo_owner, @repo_name = extract_repo_info(bug_tracker_uri)
|
|
51
|
+
@style = style
|
|
52
|
+
@uri_prefixes = self.class.normalize_uri_prefixes(uri_prefixes)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def linkify(source)
|
|
56
|
+
scan_existing_references(source) => {existing_by_key:, existing_by_ref:}
|
|
57
|
+
new_by_key = {}
|
|
58
|
+
new_by_ref = {}
|
|
59
|
+
replacements = []
|
|
60
|
+
processed_positions = Set.new
|
|
61
|
+
|
|
62
|
+
doc = Kramdown::Document.new(source, input: "GFM")
|
|
63
|
+
|
|
64
|
+
walk(doc.root) do |element, ancestors|
|
|
65
|
+
next unless element.type == :text
|
|
66
|
+
next if in_skip_context?(ancestors)
|
|
67
|
+
|
|
68
|
+
text = element.value
|
|
69
|
+
|
|
70
|
+
# Find this text occurrence in source that we haven't processed yet
|
|
71
|
+
source_offset = 0
|
|
72
|
+
while (text_pos = source.index(text, source_offset))
|
|
73
|
+
break if processed_positions.include?(text_pos)
|
|
74
|
+
|
|
75
|
+
# Mark this position as processed
|
|
76
|
+
processed_positions.add(text_pos)
|
|
77
|
+
|
|
78
|
+
# Scan for patterns in this text occurrence
|
|
79
|
+
scan_usernames(source, text, text_pos, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
80
|
+
scan_owner_repo_issues(source, text, text_pos, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
81
|
+
scan_issue_mentions(source, text, text_pos, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
82
|
+
scan_github_uris(source, text, text_pos, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
83
|
+
scan_github_repo_uris(source, text, text_pos, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
84
|
+
scan_github_user_uris(source, text, text_pos, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
85
|
+
|
|
86
|
+
break # Only process first unprocessed occurrence
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
return {markdown: source, changed: false} if replacements.empty?
|
|
91
|
+
|
|
92
|
+
# Apply replacements in reverse
|
|
93
|
+
result = source.dup
|
|
94
|
+
|
|
95
|
+
replacements.reverse_each do |r|
|
|
96
|
+
result[r[:start]...r[:end]] = r[:replacement]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Append new references if using reference style
|
|
100
|
+
if @style == :reference && !new_by_key.empty?
|
|
101
|
+
result = append_references(result, new_by_key)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
{markdown: result, changed: true}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def extract_repo_info(uri)
|
|
110
|
+
return [nil, nil] unless uri
|
|
111
|
+
|
|
112
|
+
match = uri.match(%r{github\.com/([^/]+)/([^/]+)})
|
|
113
|
+
match ? [match[1], match[2]] : [nil, nil]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def walk(element, ancestors = [], &block)
|
|
117
|
+
new_ancestors = ancestors + [element]
|
|
118
|
+
yield element, ancestors
|
|
119
|
+
element&.children&.each { |child| walk(child, new_ancestors, &block) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def in_skip_context?(ancestors)
|
|
123
|
+
ancestors.any? { |el| %i[codeblock codespan a html_element].include?(el.type) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def scan_existing_references(source)
|
|
127
|
+
existing_by_key = {}
|
|
128
|
+
existing_by_ref = {}
|
|
129
|
+
|
|
130
|
+
source.scan(/^\[([^\]]+)\]:\s*(.+)$/) do |key, uri|
|
|
131
|
+
existing_by_key[key] = uri.strip
|
|
132
|
+
existing_by_ref[uri.strip] ||= key
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
{existing_by_key:, existing_by_ref:}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def scan_usernames(source, text, base_offset, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
139
|
+
offset = 0
|
|
140
|
+
|
|
141
|
+
while (match = text.match(USERNAME_PATTERN, offset))
|
|
142
|
+
pos = base_offset + match.begin(0)
|
|
143
|
+
username = match[1]
|
|
144
|
+
|
|
145
|
+
# Validate username
|
|
146
|
+
next offset = pos + match[0].length unless valid_username?(username)
|
|
147
|
+
|
|
148
|
+
# Check the actual source to ensure no consecutive hyphens follow
|
|
149
|
+
# (kramdown may split text nodes at typographic symbols like --)
|
|
150
|
+
source_match = source[pos, username.length + 3]
|
|
151
|
+
next offset = pos + match[0].length if source_match&.include?("--")
|
|
152
|
+
|
|
153
|
+
next offset = pos + match[0].length if already_linked?(source, pos)
|
|
154
|
+
next offset = pos + match[0].length if overlaps_replacement?(replacements, pos, match[0].length)
|
|
155
|
+
|
|
156
|
+
uri = "https://github.com/#{username}"
|
|
157
|
+
link_text = "@#{username}"
|
|
158
|
+
ref_base = "gh-user-#{username}"
|
|
159
|
+
|
|
160
|
+
replacement = if @style == :reference
|
|
161
|
+
ref_id = find_or_create_ref_id(ref_base, uri, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
162
|
+
"[#{link_text}][#{ref_id}]"
|
|
163
|
+
else
|
|
164
|
+
"[#{link_text}](#{uri})"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
replacements << {start: pos, end: pos + match[0].length, replacement:}
|
|
168
|
+
offset = pos + match[0].length
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def scan_issue_mentions(source, text, base_offset, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
173
|
+
return unless @repo_owner && @repo_name
|
|
174
|
+
|
|
175
|
+
pattern = /#(\d+)(?=\s|$|[^\d])/
|
|
176
|
+
offset = 0
|
|
177
|
+
|
|
178
|
+
while (match = text.match(pattern, offset))
|
|
179
|
+
pos = base_offset + match.begin(0)
|
|
180
|
+
number = match[1]
|
|
181
|
+
|
|
182
|
+
next offset = pos + match[0].length if already_linked?(source, pos)
|
|
183
|
+
next offset = pos + match[0].length if overlaps_replacement?(replacements, pos, match[0].length)
|
|
184
|
+
|
|
185
|
+
uri = "https://github.com/#{@repo_owner}/#{@repo_name}/issues/#{number}"
|
|
186
|
+
link_text = "##{number}"
|
|
187
|
+
ref_base = "gh-issue-#{number}"
|
|
188
|
+
|
|
189
|
+
replacement = if @style == :reference
|
|
190
|
+
ref_id = find_or_create_ref_id(ref_base, uri, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
191
|
+
"[#{link_text}][#{ref_id}]"
|
|
192
|
+
else
|
|
193
|
+
"[#{link_text}](#{uri})"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
replacements << {start: pos, end: pos + match[0].length, replacement:}
|
|
197
|
+
offset = pos + match[0].length
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def scan_github_uris(source, text, base_offset, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
202
|
+
pattern = %r{https://github\.com/([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)/(issues|pull|discussions)/(\d+)(?:#(issuecomment|discussioncomment|discussion_r)-?(\d+))?([?#][^\s)]*)?}
|
|
203
|
+
offset = 0
|
|
204
|
+
|
|
205
|
+
while (match = text.match(pattern, offset))
|
|
206
|
+
pos = base_offset + match.begin(0)
|
|
207
|
+
owner = match[1]
|
|
208
|
+
repo = match[2]
|
|
209
|
+
type = match[3]
|
|
210
|
+
number = match[4]
|
|
211
|
+
comment_type = match[5]
|
|
212
|
+
comment_id = match[6]
|
|
213
|
+
extra_fragment = match[7]
|
|
214
|
+
|
|
215
|
+
next offset = pos + match[0].length if already_linked?(source, pos)
|
|
216
|
+
next offset = pos + match[0].length if overlaps_replacement?(replacements, pos, match[0].length)
|
|
217
|
+
|
|
218
|
+
# Build URI (preserve all fragments and query params)
|
|
219
|
+
uri = "https://github.com/#{owner}/#{repo}/#{type}/#{number}"
|
|
220
|
+
if comment_type
|
|
221
|
+
separator = (comment_type == "discussion_r") ? "" : "-"
|
|
222
|
+
uri += "##{comment_type}#{separator}#{comment_id}"
|
|
223
|
+
end
|
|
224
|
+
uri += extra_fragment if extra_fragment
|
|
225
|
+
|
|
226
|
+
# Build link text
|
|
227
|
+
is_same_repo = @repo_owner == owner && @repo_name == repo
|
|
228
|
+
link_text = is_same_repo ? "##{number}" : "#{owner}/#{repo}##{number}"
|
|
229
|
+
|
|
230
|
+
# Add comment indicator for comment fragments
|
|
231
|
+
if comment_type
|
|
232
|
+
link_text += (comment_type == "discussion_r") ? " (review comment)" : " (comment)"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Apply prefix if configured
|
|
236
|
+
type_key = case type
|
|
237
|
+
when "issues" then :issue
|
|
238
|
+
when "pull" then :pull
|
|
239
|
+
when "discussions" then :discussion
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
if is_same_repo && type_key && @uri_prefixes[type_key]
|
|
243
|
+
link_text = "#{@uri_prefixes[type_key]} #{link_text}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
replacement = if @style == :reference
|
|
247
|
+
ref_base = is_same_repo ? "gh-issue-#{number}" : "gh-#{owner}-#{repo}-#{number}"
|
|
248
|
+
ref_id = find_or_create_ref_id(ref_base, uri, existing_by_key, existing_by_ref, new_by_key, new_by_ref, comment_id)
|
|
249
|
+
"[#{link_text}][#{ref_id}]"
|
|
250
|
+
else
|
|
251
|
+
"[#{link_text}](#{uri})"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
replacements << {start: pos, end: pos + match[0].length, replacement:}
|
|
255
|
+
offset = pos + match[0].length
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def scan_owner_repo_issues(source, text, base_offset, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
260
|
+
pattern = %r{([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)#(\d+)(?=\s|$|[^\d])}
|
|
261
|
+
offset = 0
|
|
262
|
+
|
|
263
|
+
while (match = text.match(pattern, offset))
|
|
264
|
+
pos = base_offset + match.begin(0)
|
|
265
|
+
owner = match[1]
|
|
266
|
+
repo = match[2]
|
|
267
|
+
number = match[3]
|
|
268
|
+
|
|
269
|
+
next offset = pos + match[0].length if already_linked?(source, pos)
|
|
270
|
+
next offset = pos + match[0].length if overlaps_replacement?(replacements, pos, match[0].length)
|
|
271
|
+
|
|
272
|
+
uri = "https://github.com/#{owner}/#{repo}/issues/#{number}"
|
|
273
|
+
link_text = "#{owner}/#{repo}##{number}"
|
|
274
|
+
ref_base = "gh-#{owner}-#{repo}-#{number}"
|
|
275
|
+
|
|
276
|
+
replacement = if @style == :reference
|
|
277
|
+
ref_id = find_or_create_ref_id(ref_base, uri, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
278
|
+
"[#{link_text}][#{ref_id}]"
|
|
279
|
+
else
|
|
280
|
+
"[#{link_text}](#{uri})"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
replacements << {start: pos, end: pos + match[0].length, replacement:}
|
|
284
|
+
offset = pos + match[0].length
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def scan_github_user_uris(source, text, base_offset, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
289
|
+
pattern = %r{https://github\.com/([A-Za-z0-9_-]+)(?=/|\s|$)}
|
|
290
|
+
offset = 0
|
|
291
|
+
|
|
292
|
+
while (match = text.match(pattern, offset))
|
|
293
|
+
pos = base_offset + match.begin(0)
|
|
294
|
+
username = match[1]
|
|
295
|
+
|
|
296
|
+
next offset = pos + match[0].length unless valid_username?(username)
|
|
297
|
+
next offset = pos + match[0].length if already_linked?(source, pos)
|
|
298
|
+
next offset = pos + match[0].length if overlaps_replacement?(replacements, pos, match[0].length)
|
|
299
|
+
|
|
300
|
+
uri = "https://github.com/#{username}"
|
|
301
|
+
link_text = "@#{username}"
|
|
302
|
+
ref_base = "gh-user-#{username}"
|
|
303
|
+
|
|
304
|
+
replacement = if @style == :reference
|
|
305
|
+
ref_id = find_or_create_ref_id(ref_base, uri, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
306
|
+
"[#{link_text}][#{ref_id}]"
|
|
307
|
+
else
|
|
308
|
+
"[#{link_text}](#{uri})"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
replacements << {start: pos, end: pos + match[0].length, replacement:}
|
|
312
|
+
offset = pos + match[0].length
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def scan_github_repo_uris(source, text, base_offset, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
317
|
+
pattern = %r{https://github\.com/([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)(?=/|\s|$)}
|
|
318
|
+
offset = 0
|
|
319
|
+
|
|
320
|
+
while (match = text.match(pattern, offset))
|
|
321
|
+
pos = base_offset + match.begin(0)
|
|
322
|
+
owner = match[1]
|
|
323
|
+
repo = match[2]
|
|
324
|
+
|
|
325
|
+
next offset = pos + match[0].length if already_linked?(source, pos)
|
|
326
|
+
next offset = pos + match[0].length if overlaps_replacement?(replacements, pos, match[0].length)
|
|
327
|
+
|
|
328
|
+
uri = "https://github.com/#{owner}/#{repo}"
|
|
329
|
+
link_text = "#{owner}/#{repo}"
|
|
330
|
+
ref_base = "gh-#{owner}-#{repo}"
|
|
331
|
+
|
|
332
|
+
replacement = if @style == :reference
|
|
333
|
+
ref_id = find_or_create_ref_id(ref_base, uri, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
|
|
334
|
+
"[#{link_text}][#{ref_id}]"
|
|
335
|
+
else
|
|
336
|
+
"[#{link_text}](#{uri})"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
replacements << {start: pos, end: pos + match[0].length, replacement:}
|
|
340
|
+
offset = pos + match[0].length
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def already_linked?(source, offset)
|
|
345
|
+
# Check if preceded by [ or ]( within reasonable distance
|
|
346
|
+
check_start = [offset - 100, 0].max
|
|
347
|
+
preceding = source[check_start...offset]
|
|
348
|
+
|
|
349
|
+
# If we find [text] or [text]( before our position, we're likely in a link
|
|
350
|
+
if /\[[^\]]*\](?:\([^)]*)?$/.match?(preceding)
|
|
351
|
+
return true
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Check if we're inside a link reference [text][ref] or inline link [text](url)
|
|
355
|
+
if /\[[^\]]*$/.match?(preceding)
|
|
356
|
+
return true
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Check if followed by ]
|
|
360
|
+
following = source[offset, 10]
|
|
361
|
+
if following&.match(/^[^\[]*\]/)
|
|
362
|
+
return true
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
false
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def overlaps_replacement?(replacements, start_pos, length)
|
|
369
|
+
end_pos = start_pos + length
|
|
370
|
+
replacements.any? { |r| (start_pos < r[:end]) && (end_pos > r[:start]) }
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def make_ref_id(text, suffix = nil)
|
|
374
|
+
base = text.gsub(/[^A-Za-z0-9-]/, "-").downcase.gsub(/^-+|-+$/, "")
|
|
375
|
+
base = "#{base}-#{suffix}" if suffix
|
|
376
|
+
base
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def find_or_create_ref_id(link_text, uri, existing_by_key, existing_by_ref, new_by_key, new_by_ref, suffix = nil)
|
|
380
|
+
# Check if URI already has a ref
|
|
381
|
+
return existing_by_ref[uri] if existing_by_ref.key?(uri)
|
|
382
|
+
return new_by_ref[uri] if new_by_ref.key?(uri)
|
|
383
|
+
|
|
384
|
+
# Generate new unique ref ID
|
|
385
|
+
base = make_ref_id(link_text, suffix)
|
|
386
|
+
ref_id = base
|
|
387
|
+
counter = 2
|
|
388
|
+
|
|
389
|
+
while existing_by_key.key?(ref_id) || new_by_key.key?(ref_id)
|
|
390
|
+
ref_id = "#{base}-#{counter}"
|
|
391
|
+
counter += 1
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Register in both indexes
|
|
395
|
+
new_by_key[ref_id] = uri
|
|
396
|
+
new_by_ref[uri] = ref_id
|
|
397
|
+
|
|
398
|
+
ref_id
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def append_references(markdown, new_by_key)
|
|
402
|
+
return markdown if new_by_key.empty?
|
|
403
|
+
|
|
404
|
+
result = markdown.chomp
|
|
405
|
+
|
|
406
|
+
if markdown.match?(/^\[.+?\]:\s+.+\z/m)
|
|
407
|
+
result << "\n" unless result.end_with?("\n")
|
|
408
|
+
else
|
|
409
|
+
result << "\n\n" unless result.end_with?("\n\n")
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
new_by_key.each { |key, uri| result << "[#{key}]: #{uri}\n" }
|
|
413
|
+
result
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def valid_username?(username)
|
|
417
|
+
return false if username.start_with?("-") || username.end_with?("-")
|
|
418
|
+
return false if username.include?("--")
|
|
419
|
+
return false if username.length > 39 || username.length < 1
|
|
420
|
+
|
|
421
|
+
true
|
|
422
|
+
end
|
|
423
|
+
end
|