mbeditor 0.3.7 → 0.3.9

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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "timeout"
4
5
 
5
6
  module Mbeditor
6
7
  # Shared helpers for running git CLI commands read-only inside a repo.
@@ -10,16 +11,37 @@ module Mbeditor
10
11
  module_function
11
12
 
12
13
  # Safe pattern for git ref names (branch, remote/branch, tag).
13
- # Rejects refs containing whitespace, NUL, shell metacharacters, or
14
- # git reflog syntax (e.g. "@{" sequences beyond the trailing "@{u}").
14
+ # Excludes @ to prevent reflog syntax like @{-1} or @{u}.
15
15
  SAFE_GIT_REF = %r{\A[\w./-]+\z}
16
16
 
17
17
  # Run an arbitrary git command inside +repo_path+.
18
18
  # Returns [stdout, Process::Status]. stderr is captured and discarded to
19
19
  # prevent git diagnostic messages from leaking into the Rails server log.
20
+ # Honors config.git_timeout (seconds) when set.
20
21
  def run_git(repo_path, *args)
21
- out, _err, status = Open3.capture3("git", "-C", repo_path, *args)
22
- [out, status]
22
+ timeout_secs = Mbeditor.configuration.git_timeout&.to_i
23
+ out = +""; timed_out = false; exit_status = nil
24
+
25
+ Open3.popen3("git", "-C", repo_path, *args, pgroup: true) do |stdin, stdout, _stderr, wait_thr|
26
+ stdin.close
27
+
28
+ timer = if timeout_secs && timeout_secs > 0
29
+ Thread.new do
30
+ sleep timeout_secs
31
+ timed_out = true
32
+ Process.kill("-KILL", wait_thr.pid)
33
+ rescue Errno::ESRCH
34
+ nil
35
+ end
36
+ end
37
+
38
+ out = stdout.read
39
+ exit_status = wait_thr.value
40
+ timer&.kill
41
+ end
42
+
43
+ raise Timeout::Error, "git timed out after #{timeout_secs}s" if timed_out
44
+ [out, exit_status]
23
45
  end
24
46
 
25
47
  # Current branch name, or nil if not in a git repo.
@@ -51,36 +73,57 @@ module Mbeditor
51
73
  [parts[0].to_i, parts[1].to_i]
52
74
  end
53
75
 
76
+ # Returns [merge_base_sha, ref_name] of the first candidate base branch found,
77
+ # or [nil, nil] if none can be determined. Candidates are tried in preference
78
+ # order; skips the current branch and refs whose merge-base equals HEAD.
79
+ def find_branch_base(repo_path, current_branch, candidates: nil)
80
+ candidates ||= Mbeditor.configuration.base_branch_candidates
81
+ head_sha_out, = run_git(repo_path, "rev-parse", "HEAD")
82
+ head_sha = head_sha_out.strip
83
+
84
+ candidates.each do |ref|
85
+ short = ref.delete_prefix("origin/")
86
+ next if short == current_branch || ref == current_branch
87
+
88
+ _o, st = run_git(repo_path, "rev-parse", "--verify", "--quiet", ref)
89
+ next unless st.success?
90
+
91
+ base_out, base_st = run_git(repo_path, "merge-base", "HEAD", ref)
92
+ next unless base_st.success?
93
+
94
+ sha = base_out.strip
95
+ next unless sha.match?(/\A[0-9a-f]{40}\z/)
96
+ next if sha == head_sha
97
+
98
+ return [sha, ref]
99
+ end
100
+
101
+ [nil, nil]
102
+ rescue StandardError
103
+ [nil, nil]
104
+ end
105
+
106
+ # Parse `git diff --numstat` output.
107
+ # Returns Hash of path => { added: Integer, removed: Integer }.
108
+ def parse_numstat(output)
109
+ (output || "").lines.each_with_object({}) do |line, map|
110
+ parts = line.strip.split("\t", 3)
111
+ next if parts.length < 3 || parts[0] == "-"
112
+
113
+ map[parts[2].strip] = { added: parts[0].to_i, removed: parts[1].to_i }
114
+ end
115
+ end
116
+
54
117
  # Parse compact `git log --pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%aI%x1e` output.
55
- # Returns Array of hashes.
118
+ # Returns Array of hashes with string keys.
56
119
  def self.parse_git_log_with_parents(raw_output)
57
- raw_output.split("\x1e").map do |entry|
58
- fields = entry.strip.split("\x1f", 5)
59
- next unless fields.length == 5
60
-
61
- {
62
- "hash" => fields[0],
63
- "parents" => fields[1].split.reject(&:blank?),
64
- "title" => fields[2],
65
- "author" => fields[3],
66
- "date" => fields[4]
67
- }
68
- end.compact
120
+ parse_log_entries(raw_output, with_parents: true)
69
121
  end
70
122
 
71
123
  # Parse compact `git log --pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e` output.
124
+ # Returns Array of hashes with string keys.
72
125
  def self.parse_git_log(raw_output)
73
- raw_output.split("\x1e").map do |entry|
74
- fields = entry.strip.split("\x1f", 4)
75
- next unless fields.length == 4
76
-
77
- {
78
- "hash" => fields[0],
79
- "title" => fields[1],
80
- "author" => fields[2],
81
- "date" => fields[3]
82
- }
83
- end.compact
126
+ parse_log_entries(raw_output, with_parents: false)
84
127
  end
85
128
 
86
129
  # Resolve a file path safely within repo_path. Returns full path string or
@@ -91,5 +134,31 @@ module Mbeditor
91
134
  full = File.expand_path(relative.to_s, repo_path.to_s)
92
135
  full.start_with?(repo_path.to_s + "/") || full == repo_path.to_s ? full : nil
93
136
  end
137
+
138
+ def self.parse_log_entries(raw_output, with_parents:)
139
+ field_count = with_parents ? 5 : 4
140
+ raw_output.split("\x1e").filter_map do |entry|
141
+ fields = entry.strip.split("\x1f", field_count)
142
+ next unless fields.length == field_count
143
+
144
+ if with_parents
145
+ {
146
+ "hash" => fields[0],
147
+ "parents" => fields[1].split.reject(&:blank?),
148
+ "title" => fields[2],
149
+ "author" => fields[3],
150
+ "date" => fields[4]
151
+ }
152
+ else
153
+ {
154
+ "hash" => fields[0],
155
+ "title" => fields[1],
156
+ "author" => fields[2],
157
+ "date" => fields[3]
158
+ }
159
+ end
160
+ end
161
+ end
162
+ private_class_method :parse_log_entries
94
163
  end
95
164
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "net/http"
4
+ require "openssl"
4
5
  require "uri"
5
6
  require "json"
6
7
 
@@ -42,6 +43,9 @@ module Mbeditor
42
43
  raise RedmineConfigError, "redmine_url is not configured" if config.redmine_url.blank?
43
44
  raise RedmineConfigError, "redmine_api_key is not configured" if config.redmine_api_key.blank?
44
45
 
46
+ uri = URI.parse(config.redmine_url.to_s.chomp("/"))
47
+ raise RedmineConfigError, "redmine_url must use http or https scheme" unless %w[http https].include?(uri.scheme)
48
+
45
49
  fetch_issue(config.redmine_url, config.redmine_api_key)
46
50
  end
47
51
 
@@ -52,6 +56,9 @@ module Mbeditor
52
56
 
53
57
  http = Net::HTTP.new(uri.host, uri.port)
54
58
  http.use_ssl = uri.scheme == "https"
59
+ if uri.scheme == "https"
60
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
61
+ end
55
62
  http.open_timeout = TIMEOUT_SECONDS
56
63
  http.read_timeout = TIMEOUT_SECONDS
57
64
 
@@ -22,6 +22,7 @@ module Mbeditor
22
22
  class RubyDefinitionService
23
23
  MAX_RESULTS = 20
24
24
  MAX_COMMENT_LOOKAHEAD = 15
25
+ MAX_FILES_SCANNED = 10_000
25
26
 
26
27
  # In-process file-index cache.
27
28
  # Structure: { absolute_path => { mtime: Float, lines: [String], all_defs: { method_name => [line, ...] } } }
@@ -101,8 +102,11 @@ module Mbeditor
101
102
  def call
102
103
  self.class.load_disk_cache_once
103
104
 
104
- results = []
105
- @new_entries = false
105
+ results = []
106
+ @new_entries = false
107
+ files_scanned = 0
108
+
109
+ evict_deleted_cache_entries
106
110
 
107
111
  Find.find(@workspace_root) do |path|
108
112
  # Prune excluded directories
@@ -120,6 +124,12 @@ module Mbeditor
120
124
  rel = relative_path(path)
121
125
  next if excluded_rel_path?(rel, File.basename(path))
122
126
 
127
+ files_scanned += 1
128
+ if files_scanned > MAX_FILES_SCANNED
129
+ Rails.logger.warn("[mbeditor] RubyDefinitionService: workspace exceeds #{MAX_FILES_SCANNED} .rb files; stopping scan early")
130
+ break
131
+ end
132
+
123
133
  begin
124
134
  cached = cache_entry_for(path)
125
135
  next unless cached
@@ -147,6 +157,17 @@ module Mbeditor
147
157
 
148
158
  private
149
159
 
160
+ # Remove cache entries for files that no longer exist on disk.
161
+ def evict_deleted_cache_entries
162
+ stale_keys = self.class.mutex.synchronize do
163
+ self.class.file_cache.keys.select { |p| !File.exist?(p) }
164
+ end
165
+ return if stale_keys.empty?
166
+
167
+ self.class.mutex.synchronize { stale_keys.each { |k| self.class.file_cache.delete(k) } }
168
+ @new_entries = true
169
+ end
170
+
150
171
  # Returns the cached index entry for +path+, rebuilding it if the file has
151
172
  # been modified since the last parse. Returns nil on any read/parse error.
152
173
  def cache_entry_for(path)
@@ -1,9 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mbeditor
2
4
  class Configuration
3
5
  attr_accessor :allowed_environments, :workspace_root, :excluded_paths, :rubocop_command,
4
6
  :redmine_enabled, :redmine_url, :redmine_api_key, :redmine_ticket_source,
5
7
  :test_framework, :test_command, :test_timeout,
6
- :authenticate_with
8
+ :authenticate_with,
9
+ :lint_timeout, :base_branch_candidates, :git_timeout
7
10
 
8
11
  def initialize
9
12
  @allowed_environments = [:development]
@@ -17,6 +20,9 @@ module Mbeditor
17
20
  @test_framework = nil # :minitest or :rspec — auto-detected when nil
18
21
  @test_command = nil # e.g. "bundle exec ruby -Itest" or "bundle exec rspec"
19
22
  @test_timeout = 60 # seconds
23
+ @lint_timeout = 15 # seconds for RuboCop/haml-lint subprocesses
24
+ @base_branch_candidates = %w[origin/develop origin/main origin/master develop main master]
25
+ @git_timeout = nil # seconds; nil disables (no timeout on git subprocesses)
20
26
  end
21
27
  end
22
28
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "mbeditor/rack/silence_ping_request"
2
4
  require "mbeditor/rack/handle_pending_migrations"
3
5
 
@@ -24,6 +26,31 @@ module Mbeditor
24
26
  config.after_initialize do
25
27
  Mbeditor::RubyDefinitionService.cache_path =
26
28
  Rails.root.join("tmp", "mbeditor_ruby_defs.json").to_s
29
+
30
+ cfg = Mbeditor.configuration
31
+
32
+ if cfg.workspace_root.present? && !File.directory?(cfg.workspace_root.to_s)
33
+ raise ArgumentError, "[mbeditor] config.workspace_root is set to '#{cfg.workspace_root}' but that path is not a directory"
34
+ end
35
+
36
+ if cfg.redmine_enabled
37
+ require "uri"
38
+ if cfg.redmine_url.blank?
39
+ Rails.logger.warn("[mbeditor] redmine_enabled is true but redmine_url is not configured")
40
+ else
41
+ begin
42
+ uri = URI.parse(cfg.redmine_url.to_s)
43
+ unless %w[http https].include?(uri.scheme)
44
+ Rails.logger.warn("[mbeditor] redmine_url must use http or https scheme")
45
+ end
46
+ rescue URI::InvalidURIError
47
+ Rails.logger.warn("[mbeditor] redmine_url is not a valid URI")
48
+ end
49
+ end
50
+ if cfg.redmine_api_key.blank?
51
+ Rails.logger.warn("[mbeditor] redmine_enabled is true but redmine_api_key is not configured")
52
+ end
53
+ end
27
54
  end
28
55
 
29
56
  initializer "mbeditor.assets.precompile" do |app|
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mbeditor
2
- VERSION = "0.3.7"
4
+ VERSION = "0.3.9"
3
5
  end
data/lib/mbeditor.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "mbeditor/version"
2
4
  require "mbeditor/configuration"
3
5
  require "mbeditor/engine"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mbeditor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.7
4
+ version: 0.3.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oliver Noonan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-16 00:00:00.000000000 Z
11
+ date: 2026-04-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails