mbeditor 0.2.2

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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +116 -0
  3. data/README.md +180 -0
  4. data/app/assets/javascripts/mbeditor/application.js +21 -0
  5. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +202 -0
  6. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +71 -0
  7. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +139 -0
  8. data/app/assets/javascripts/mbeditor/components/CommitGraph.js +65 -0
  9. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +166 -0
  10. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +1139 -0
  11. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +117 -0
  12. data/app/assets/javascripts/mbeditor/components/FileTree.js +339 -0
  13. data/app/assets/javascripts/mbeditor/components/GitPanel.js +501 -0
  14. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +3108 -0
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +272 -0
  16. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +186 -0
  17. data/app/assets/javascripts/mbeditor/components/TabBar.js +238 -0
  18. data/app/assets/javascripts/mbeditor/components/TestResultsPanel.js +150 -0
  19. data/app/assets/javascripts/mbeditor/editor_plugins.js +758 -0
  20. data/app/assets/javascripts/mbeditor/editor_store.js +69 -0
  21. data/app/assets/javascripts/mbeditor/file_icon.js +30 -0
  22. data/app/assets/javascripts/mbeditor/file_service.js +96 -0
  23. data/app/assets/javascripts/mbeditor/git_service.js +104 -0
  24. data/app/assets/javascripts/mbeditor/search_service.js +63 -0
  25. data/app/assets/javascripts/mbeditor/tab_manager.js +485 -0
  26. data/app/assets/stylesheets/mbeditor/application.css +848 -0
  27. data/app/assets/stylesheets/mbeditor/editor.css +2061 -0
  28. data/app/controllers/mbeditor/application_controller.rb +70 -0
  29. data/app/controllers/mbeditor/editors_controller.rb +996 -0
  30. data/app/controllers/mbeditor/git_controller.rb +234 -0
  31. data/app/services/mbeditor/git_blame_service.rb +98 -0
  32. data/app/services/mbeditor/git_commit_graph_service.rb +60 -0
  33. data/app/services/mbeditor/git_diff_service.rb +74 -0
  34. data/app/services/mbeditor/git_file_history_service.rb +42 -0
  35. data/app/services/mbeditor/git_service.rb +95 -0
  36. data/app/services/mbeditor/redmine_service.rb +86 -0
  37. data/app/services/mbeditor/ruby_definition_service.rb +168 -0
  38. data/app/services/mbeditor/test_runner_service.rb +286 -0
  39. data/app/views/layouts/mbeditor/application.html.erb +120 -0
  40. data/app/views/mbeditor/editors/index.html.erb +1 -0
  41. data/config/initializers/assets.rb +9 -0
  42. data/config/routes.rb +44 -0
  43. data/lib/mbeditor/configuration.rb +22 -0
  44. data/lib/mbeditor/engine.rb +37 -0
  45. data/lib/mbeditor/rack/silence_ping_request.rb +56 -0
  46. data/lib/mbeditor/version.rb +3 -0
  47. data/lib/mbeditor.rb +19 -0
  48. data/mbeditor.gemspec +31 -0
  49. data/public/mbeditor-icon.svg +4 -0
  50. data/public/min-maps/vs/base/worker/workerMain.js.map +1 -0
  51. data/public/monaco-editor/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. data/public/monaco-editor/vs/base/worker/workerMain.js +31 -0
  53. data/public/monaco-editor/vs/basic-languages/cameligo/cameligo.js +10 -0
  54. data/public/monaco-editor/vs/basic-languages/css/css.js +12 -0
  55. data/public/monaco-editor/vs/basic-languages/dart/dart.js +10 -0
  56. data/public/monaco-editor/vs/basic-languages/flow9/flow9.js +10 -0
  57. data/public/monaco-editor/vs/basic-languages/go/go.js +10 -0
  58. data/public/monaco-editor/vs/basic-languages/handlebars/handlebars.js +440 -0
  59. data/public/monaco-editor/vs/basic-languages/javascript/javascript.js +10 -0
  60. data/public/monaco-editor/vs/basic-languages/markdown/markdown.js +10 -0
  61. data/public/monaco-editor/vs/basic-languages/msdax/msdax.js +10 -0
  62. data/public/monaco-editor/vs/basic-languages/postiats/postiats.js +10 -0
  63. data/public/monaco-editor/vs/basic-languages/pug/pug.js +412 -0
  64. data/public/monaco-editor/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  65. data/public/monaco-editor/vs/basic-languages/ruby/ruby.js +10 -0
  66. data/public/monaco-editor/vs/basic-languages/sb/sb.js +10 -0
  67. data/public/monaco-editor/vs/basic-languages/shell/shell.js +41 -0
  68. data/public/monaco-editor/vs/basic-languages/typescript/typescript.js +10 -0
  69. data/public/monaco-editor/vs/basic-languages/typespec/typespec.js +10 -0
  70. data/public/monaco-editor/vs/basic-languages/yaml/yaml.js +10 -0
  71. data/public/monaco-editor/vs/editor/editor.api.js +6 -0
  72. data/public/monaco-editor/vs/editor/editor.main.css +8 -0
  73. data/public/monaco-editor/vs/editor/editor.main.js +797 -0
  74. data/public/monaco-editor/vs/language/typescript/tsMode.js +20 -0
  75. data/public/monaco-editor/vs/language/typescript/tsWorker.js +51328 -0
  76. data/public/monaco-editor/vs/loader.js +10 -0
  77. data/public/monaco-editor/vs/nls.messages.de.js +20 -0
  78. data/public/monaco-editor/vs/nls.messages.es.js +20 -0
  79. data/public/monaco-editor/vs/nls.messages.fr.js +18 -0
  80. data/public/monaco-editor/vs/nls.messages.it.js +18 -0
  81. data/public/monaco-editor/vs/nls.messages.ja.js +20 -0
  82. data/public/monaco-editor/vs/nls.messages.ko.js +18 -0
  83. data/public/monaco-editor/vs/nls.messages.ru.js +20 -0
  84. data/public/monaco-editor/vs/nls.messages.zh-cn.js +20 -0
  85. data/public/monaco-editor/vs/nls.messages.zh-tw.js +18 -0
  86. data/public/monaco_worker.js +5 -0
  87. data/public/sw.js +8 -0
  88. data/public/ts_worker.js +5 -0
  89. data/vendor/assets/javascripts/axios.min.js +5 -0
  90. data/vendor/assets/javascripts/emmet.js +5452 -0
  91. data/vendor/assets/javascripts/lodash.min.js +136 -0
  92. data/vendor/assets/javascripts/marked.min.js +6 -0
  93. data/vendor/assets/javascripts/minisearch.min.js +2044 -0
  94. data/vendor/assets/javascripts/monaco-themes-bundle.js +10 -0
  95. data/vendor/assets/javascripts/monaco-vim.js +9867 -0
  96. data/vendor/assets/javascripts/prettier-plugin-babel.js +16 -0
  97. data/vendor/assets/javascripts/prettier-plugin-estree.js +35 -0
  98. data/vendor/assets/javascripts/prettier-plugin-html.js +19 -0
  99. data/vendor/assets/javascripts/prettier-plugin-markdown.js +59 -0
  100. data/vendor/assets/javascripts/prettier-plugin-postcss.js +52 -0
  101. data/vendor/assets/javascripts/prettier-standalone.js +37 -0
  102. data/vendor/assets/javascripts/react-dom.min.js +267 -0
  103. data/vendor/assets/javascripts/react.min.js +31 -0
  104. data/vendor/assets/stylesheets/fontawesome.min.css.erb +9 -0
  105. data/vendor/assets/webfonts/fa-brands-400.woff2 +0 -0
  106. data/vendor/assets/webfonts/fa-regular-400.woff2 +0 -0
  107. data/vendor/assets/webfonts/fa-solid-900.woff2 +0 -0
  108. metadata +188 -0
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Thin controller for all Git-related endpoints added in the Git & Code Review
5
+ # system. All heavy logic lives in service objects under app/services/mbeditor/.
6
+ #
7
+ # Endpoints
8
+ # ---------
9
+ # GET /mbeditor/git/diff ?file=<path>[&base=<sha>&head=<sha>]
10
+ # GET /mbeditor/git/blame ?file=<path>
11
+ # GET /mbeditor/git/file_history ?file=<path>
12
+ # GET /mbeditor/git/commit_graph
13
+ # GET /mbeditor/redmine/issue/:id
14
+ class GitController < ApplicationController
15
+ skip_before_action :verify_authenticity_token
16
+ before_action :ensure_allowed_environment!
17
+
18
+ # GET /mbeditor/git/diff?file=<path>[&base=<sha>&head=<sha>]
19
+ def diff
20
+ file = require_file_param
21
+ return unless file
22
+
23
+ base = params[:base].presence
24
+ head = params[:head].presence
25
+ # 'WORKING' is a frontend sentinel meaning current on-disk working tree
26
+ head = nil if head == 'WORKING'
27
+ # Allow full/short SHA hashes plus common git ref formats: branch names,
28
+ # HEAD, remote tracking refs, parent notation (sha^, sha~N) and tags.
29
+ valid_ref = /\A[a-zA-Z0-9._\-\/\^~@]+\z/
30
+ if [base, head].any? { |s| s && (s.length > 200 || !s.match?(valid_ref)) }
31
+ return render json: { error: 'Invalid ref' }, status: :bad_request
32
+ end
33
+
34
+ result = GitDiffService.new(
35
+ repo_path: workspace_root,
36
+ file_path: file,
37
+ base_sha: base,
38
+ head_sha: head
39
+ ).call
40
+
41
+ render json: result
42
+ rescue StandardError => e
43
+ render json: { error: e.message }, status: :unprocessable_content
44
+ end
45
+
46
+ # GET /mbeditor/git/blame?file=<path>
47
+ def blame
48
+ file = require_file_param
49
+ return unless file
50
+
51
+ lines = GitBlameService.new(repo_path: workspace_root, file_path: file).call
52
+ render json: { lines: lines }
53
+ rescue StandardError => e
54
+ render json: { error: e.message }, status: :unprocessable_content
55
+ end
56
+
57
+ # GET /mbeditor/git/file_history?file=<path>
58
+ def file_history
59
+ file = require_file_param
60
+ return unless file
61
+
62
+ commits = GitFileHistoryService.new(repo_path: workspace_root, file_path: file).call
63
+ render json: { commits: commits }
64
+ rescue StandardError => e
65
+ render json: { error: e.message }, status: :unprocessable_content
66
+ end
67
+
68
+ # GET /mbeditor/git/commit_graph
69
+ def commit_graph
70
+ commits = GitCommitGraphService.new(repo_path: workspace_root).call
71
+ render json: { commits: commits }
72
+ rescue StandardError => e
73
+ render json: { error: e.message }, status: :unprocessable_content
74
+ end
75
+
76
+ # GET /mbeditor/git/commit_detail?sha=<hash>
77
+ def commit_detail
78
+ sha = params[:sha].to_s.strip
79
+ return render json: { error: "sha required" }, status: :bad_request if sha.blank?
80
+ return render json: { error: "Invalid sha" }, status: :bad_request unless sha.match?(/\A[0-9a-fA-F]{1,40}\z/)
81
+
82
+ files_output, _err, files_status = Open3.capture3(
83
+ "git", "-C", workspace_root.to_s,
84
+ "diff-tree", "--no-commit-id", "-r", "--name-status", sha
85
+ )
86
+ numstat_output, _err, numstat_status = Open3.capture3(
87
+ "git", "-C", workspace_root.to_s,
88
+ "diff-tree", "--no-commit-id", "-r", "--numstat", sha
89
+ )
90
+
91
+ numstat_map = {}
92
+ if numstat_status.success?
93
+ numstat_output.lines.each do |line|
94
+ parts = line.strip.split("\t", 3)
95
+ next if parts.length < 3 || parts[0] == "-"
96
+
97
+ numstat_map[parts[2].strip] = { "added" => parts[0].to_i, "removed" => parts[1].to_i }
98
+ end
99
+ end
100
+
101
+ files = []
102
+ if files_status.success?
103
+ files = files_output.lines.map do |line|
104
+ parts = line.strip.split("\t", 2)
105
+ next if parts.length < 2
106
+ file = { "status" => parts[0].strip, "path" => parts[1].strip }
107
+ file.merge(numstat_map.fetch(file["path"], {}))
108
+ end.compact
109
+ end
110
+
111
+ log_output, _err, log_status = Open3.capture3(
112
+ "git", "-C", workspace_root.to_s,
113
+ "log", "-1", "--pretty=format:%s%x1f%an%x1f%aI", sha
114
+ )
115
+ meta = {}
116
+ if log_status.success?
117
+ fields = log_output.strip.split("\x1f", 3)
118
+ meta = { "title" => fields[0].to_s, "author" => fields[1].to_s, "date" => fields[2].to_s }
119
+ end
120
+
121
+ render json: { sha: sha, title: meta["title"] || "", author: meta["author"] || "", date: meta["date"] || "", files: files }
122
+ rescue StandardError => e
123
+ render json: { error: e.message }, status: :unprocessable_content
124
+ end
125
+
126
+ # GET /mbeditor/git/combined_diff?scope=local|branch
127
+ # Returns the raw unified diff text for all files in the given scope.
128
+ # scope=local → git diff HEAD (working tree vs HEAD)
129
+ # scope=branch → git diff <branch-base>..HEAD (same baseline as git_info)
130
+ def combined_diff
131
+ scope = params[:scope] == 'branch' ? :branch : :local
132
+
133
+ if scope == :local
134
+ out, _err, status = Open3.capture3("git", "-C", workspace_root.to_s, "diff", "HEAD")
135
+ out = status.success? ? out : ""
136
+ else
137
+ repo = workspace_root.to_s
138
+ branch = GitService.current_branch(repo)
139
+ base_sha, = find_branch_base(repo, branch)
140
+
141
+ if base_sha.present?
142
+ out, _err, status = Open3.capture3("git", "-C", repo, "diff", "#{base_sha}..HEAD")
143
+ out = status.success? ? out : ""
144
+ else
145
+ upstream_out, _err, upstream_status = Open3.capture3(
146
+ "git", "-C", repo,
147
+ "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"
148
+ )
149
+ upstream = upstream_status.success? ? upstream_out.strip : nil
150
+ upstream = nil unless upstream&.match?(%r{\A[\w./-]+\z})
151
+
152
+ if upstream.present?
153
+ out, _err, status = Open3.capture3("git", "-C", repo, "diff", "#{upstream}..HEAD")
154
+ out = status.success? ? out : ""
155
+ else
156
+ out = ""
157
+ end
158
+ end
159
+ end
160
+
161
+ render plain: out, content_type: "text/plain"
162
+ rescue StandardError
163
+ render plain: "", content_type: "text/plain"
164
+ end
165
+
166
+ # GET /mbeditor/redmine/issue/:id
167
+ def redmine_issue
168
+ unless Mbeditor.configuration.redmine_enabled
169
+ return render json: { error: 'Redmine integration is disabled.' }, status: :service_unavailable
170
+ end
171
+
172
+ return render json: { error: 'Invalid issue id' }, status: :bad_request unless params[:id].to_s.match?(/\A\d+\z/)
173
+
174
+ result = RedmineService.new(issue_id: params[:id]).call
175
+ render json: result
176
+ rescue RedmineDisabledError => e
177
+ render json: { error: e.message }, status: :service_unavailable
178
+ rescue RedmineConfigError => e
179
+ render json: { error: e.message }, status: :unprocessable_content
180
+ rescue StandardError => e
181
+ render json: { error: e.message }, status: :unprocessable_content
182
+ end
183
+
184
+ private
185
+
186
+ # Require & validate a `file` query param, responding 400/403 on bad input.
187
+ # Returns the relative path string on success, or nil if already responded.
188
+ def require_file_param
189
+ raw = params[:file].to_s.strip
190
+
191
+ if raw.blank?
192
+ render json: { error: "file parameter is required" }, status: :bad_request
193
+ return nil
194
+ end
195
+
196
+ full = resolve_path(raw)
197
+ unless full
198
+ render json: { error: "Forbidden" }, status: :forbidden
199
+ return nil
200
+ end
201
+
202
+ relative_path(full)
203
+ end
204
+
205
+ # Returns [merge_base_sha, ref_name] of the first candidate base branch found,
206
+ # or [nil, nil] if none can be determined.
207
+ def find_branch_base(repo, current_branch)
208
+ candidates = %w[origin/develop origin/main origin/master develop main master]
209
+ head_sha_out, = Open3.capture3("git", "-C", repo, "rev-parse", "HEAD")
210
+ head_sha = head_sha_out.strip
211
+
212
+ candidates.each do |ref|
213
+ short = ref.delete_prefix("origin/")
214
+ next if short == current_branch || ref == current_branch
215
+
216
+ _o, _e, st = Open3.capture3("git", "-C", repo, "rev-parse", "--verify", "--quiet", ref)
217
+ next unless st.success?
218
+
219
+ base_out, _e, base_st = Open3.capture3("git", "-C", repo, "merge-base", "HEAD", ref)
220
+ next unless base_st.success?
221
+
222
+ sha = base_out.strip
223
+ next unless sha.match?(/\A[0-9a-f]{40}\z/)
224
+ next if sha == head_sha
225
+
226
+ return [sha, ref]
227
+ end
228
+
229
+ [nil, nil]
230
+ rescue StandardError
231
+ [nil, nil]
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Mbeditor
6
+ # Wraps `git blame --porcelain` and returns structured per-line blame data.
7
+ #
8
+ # Each result entry:
9
+ # {
10
+ # "line" => Integer, # 1-indexed line number
11
+ # "sha" => String, # full 40-char commit sha
12
+ # "author" => String,
13
+ # "email" => String,
14
+ # "date" => String, # ISO-8601
15
+ # "summary" => String, # commit subject line
16
+ # "content" => String # raw source line (without trailing newline)
17
+ # }
18
+ class GitBlameService
19
+ include GitService
20
+
21
+ attr_reader :repo_path, :file_path
22
+
23
+ def initialize(repo_path:, file_path:)
24
+ @repo_path = repo_path.to_s
25
+ @file_path = file_path.to_s
26
+ end
27
+
28
+ # Returns Array of line blame hashes, or raises RuntimeError.
29
+ def call
30
+ output, status = GitService.run_git(repo_path, "blame", "--porcelain", "--", file_path)
31
+ raise "git blame failed for #{file_path}" unless status.success?
32
+
33
+ parse_porcelain(output)
34
+ end
35
+
36
+ private
37
+
38
+ def parse_porcelain(output)
39
+ results = []
40
+ commits = {} # sha -> metadata cache (porcelain repeats sha for first occurrence only)
41
+ current = {}
42
+
43
+ output.each_line do |raw|
44
+ line = raw.chomp
45
+
46
+ # Header line: "<sha> <orig-line> <result-line> [<num-lines>]"
47
+ if line =~ /\A([0-9a-f]{40}) \d+ (\d+)/
48
+ sha = Regexp.last_match(1)
49
+ result_num = Regexp.last_match(2).to_i
50
+ commits[sha] ||= {}
51
+ current = { "sha" => sha, "line" => result_num }.merge(commits[sha])
52
+ next
53
+ end
54
+
55
+ # Content line (starts with TAB)
56
+ if line.start_with?("\t")
57
+ current["content"] = line[1..-1] # strip leading tab
58
+ commits[current["sha"]] = current.slice("author", "email", "date", "summary") if current["author"]
59
+ results << current.dup
60
+ current = {}
61
+ next
62
+ end
63
+
64
+ # Metadata lines
65
+ case line
66
+ when /\Aauthor (.+)/
67
+ current["author"] = Regexp.last_match(1)
68
+ commits[current["sha"]] ||= {}
69
+ commits[current["sha"]]["author"] = current["author"]
70
+ when /\Aauthor-mail <(.+)>/
71
+ current["email"] = Regexp.last_match(1)
72
+ commits[current["sha"]] ||= {}
73
+ commits[current["sha"]]["email"] = current["email"]
74
+ when /\Aauthor-time (\d+)/
75
+ ts = Regexp.last_match(1).to_i
76
+ current["date"] = Time.at(ts).utc.iso8601
77
+ commits[current["sha"]] ||= {}
78
+ commits[current["sha"]]["date"] = current["date"]
79
+ when /\Asummary (.+)/
80
+ current["summary"] = Regexp.last_match(1)
81
+ commits[current["sha"]] ||= {}
82
+ commits[current["sha"]]["summary"] = current["summary"]
83
+ end
84
+
85
+ # Merge cached metadata for repeat shas so every entry is complete
86
+ if current["sha"] && commits[current["sha"]]
87
+ cached = commits[current["sha"]]
88
+ current["author"] ||= cached["author"]
89
+ current["email"] ||= cached["email"]
90
+ current["date"] ||= cached["date"]
91
+ current["summary"] ||= cached["summary"]
92
+ end
93
+ end
94
+
95
+ results
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Builds commit graph data for rendering a VSCode-style commit graph.
5
+ #
6
+ # Each commit entry:
7
+ # {
8
+ # "hash" => String, # full 40-char sha
9
+ # "parents" => Array<String>, # parent sha(s) — enables line drawing
10
+ # "title" => String,
11
+ # "author" => String,
12
+ # "date" => String, # ISO-8601
13
+ # "isLocal" => Boolean # true if commit is ahead of upstream
14
+ # }
15
+ class GitCommitGraphService
16
+ include GitService
17
+
18
+ MAX_COMMITS = 150
19
+
20
+ attr_reader :repo_path
21
+
22
+ def initialize(repo_path:)
23
+ @repo_path = repo_path.to_s
24
+ end
25
+
26
+ def call
27
+ output, status = GitService.run_git(
28
+ repo_path,
29
+ "log",
30
+ "--all",
31
+ "-n", MAX_COMMITS.to_s,
32
+ "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%aI%x1e"
33
+ )
34
+
35
+ raise "git log failed" unless status.success?
36
+
37
+ commits = GitService.parse_git_log_with_parents(output)
38
+ local_shas = local_commit_shas
39
+
40
+ commits.map do |c|
41
+ c.merge("isLocal" => local_shas.include?(c["hash"]))
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # Returns a Set of commit shas that are ahead of the upstream (i.e. not yet pushed).
48
+ def local_commit_shas
49
+ upstream = GitService.upstream_branch(repo_path)
50
+ return Set.new if upstream.blank?
51
+
52
+ out, status = GitService.run_git(repo_path, "rev-list", "HEAD...#{upstream}", "--left-only")
53
+ return Set.new unless status.success?
54
+
55
+ Set.new(out.lines.map(&:strip).reject(&:blank?))
56
+ rescue StandardError
57
+ Set.new
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Produces original/modified content pairs suitable for Monaco's diff editor.
5
+ #
6
+ # Modes
7
+ # -----
8
+ # 1. Working tree vs HEAD (default when no sha given)
9
+ # original = HEAD version of the file
10
+ # modified = current on-disk content
11
+ #
12
+ # 2. Specific commit vs its parent
13
+ # original = file at <base_sha>
14
+ # modified = file at <head_sha>
15
+ class GitDiffService
16
+ include GitService
17
+
18
+ attr_reader :repo_path, :file_path, :base_sha, :head_sha
19
+
20
+ def initialize(repo_path:, file_path:, base_sha: nil, head_sha: nil)
21
+ @repo_path = repo_path.to_s
22
+ @file_path = file_path.to_s
23
+ @base_sha = base_sha.presence
24
+ @head_sha = head_sha.presence
25
+ end
26
+
27
+ # Returns { original: String, modified: String } or raises RuntimeError.
28
+ def call
29
+ if base_sha && head_sha
30
+ diff_between_commits
31
+ elsif base_sha
32
+ # base_sha provided but head_sha is nil: diff that ref vs the working tree
33
+ { "original" => file_at_ref(base_sha, file_path), "modified" => on_disk_content }
34
+ else
35
+ diff_working_tree_vs_head
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def diff_working_tree_vs_head
42
+ original = file_at_ref("HEAD", file_path)
43
+ modified = on_disk_content
44
+
45
+ { "original" => original, "modified" => modified }
46
+ end
47
+
48
+ def diff_between_commits
49
+ original = file_at_ref(base_sha, file_path)
50
+ modified = file_at_ref(head_sha, file_path)
51
+
52
+ { "original" => original, "modified" => modified }
53
+ end
54
+
55
+ # Return the file content at a given git ref, or "" if it doesn't exist.
56
+ def file_at_ref(ref, path)
57
+ out, status = GitService.run_git(repo_path, "show", "#{ref}:#{path}")
58
+ return "" unless status.success?
59
+
60
+ out
61
+ rescue StandardError
62
+ ""
63
+ end
64
+
65
+ def on_disk_content
66
+ full = GitService.resolve_path(repo_path, file_path)
67
+ return "" unless full && File.file?(full)
68
+
69
+ File.read(full, encoding: "UTF-8", invalid: :replace, undef: :replace)
70
+ rescue StandardError
71
+ ""
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Returns the per-file commit history using `git log --follow`.
5
+ #
6
+ # Each entry:
7
+ # {
8
+ # "hash" => String,
9
+ # "title" => String,
10
+ # "author" => String,
11
+ # "date" => String # ISO-8601
12
+ # }
13
+ class GitFileHistoryService
14
+ include GitService
15
+
16
+ MAX_COMMITS = 200
17
+
18
+ attr_reader :repo_path, :file_path
19
+
20
+ def initialize(repo_path:, file_path:)
21
+ @repo_path = repo_path.to_s
22
+ @file_path = file_path.to_s
23
+ end
24
+
25
+ # Returns Array of commit hashes for the file.
26
+ def call
27
+ output, status = GitService.run_git(
28
+ repo_path,
29
+ "log",
30
+ "--follow",
31
+ "-n", MAX_COMMITS.to_s,
32
+ "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e",
33
+ "--",
34
+ file_path
35
+ )
36
+
37
+ raise "git log failed for #{file_path}" unless status.success?
38
+
39
+ GitService.parse_git_log(output)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Mbeditor
6
+ # Shared helpers for running git CLI commands read-only inside a repo.
7
+ # All public methods accept +repo_path+ as their first argument so services
8
+ # stay stateless and composable.
9
+ module GitService
10
+ module_function
11
+
12
+ # 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}").
15
+ SAFE_GIT_REF = %r{\A[\w./-]+\z}
16
+
17
+ # Run an arbitrary git command inside +repo_path+.
18
+ # Returns [stdout, Process::Status]. stderr is captured and discarded to
19
+ # prevent git diagnostic messages from leaking into the Rails server log.
20
+ def run_git(repo_path, *args)
21
+ out, _err, status = Open3.capture3("git", "-C", repo_path, *args)
22
+ [out, status]
23
+ end
24
+
25
+ # Current branch name, or nil if not in a git repo.
26
+ # Uses rev-parse for compatibility with Git < 2.22 (which lacks --show-current).
27
+ def current_branch(repo_path)
28
+ out, status = run_git(repo_path, "rev-parse", "--abbrev-ref", "HEAD")
29
+ status.success? ? out.strip : nil
30
+ end
31
+
32
+ # Upstream tracking branch for the current branch, e.g. "origin/main".
33
+ # Returns nil if the branch name contains characters outside SAFE_GIT_REF.
34
+ def upstream_branch(repo_path)
35
+ out, status = run_git(repo_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
36
+ return nil unless status.success?
37
+
38
+ ref = out.strip
39
+ ref.match?(SAFE_GIT_REF) ? ref : nil
40
+ end
41
+
42
+ # Returns [ahead_count, behind_count] relative to upstream, or [0,0].
43
+ def ahead_behind(repo_path, upstream)
44
+ return [0, 0] if upstream.blank?
45
+ return [0, 0] unless upstream.match?(SAFE_GIT_REF)
46
+
47
+ out, status = run_git(repo_path, "rev-list", "--left-right", "--count", "HEAD...#{upstream}")
48
+ return [0, 0] unless status.success?
49
+
50
+ parts = out.strip.split("\t", 2)
51
+ [parts[0].to_i, parts[1].to_i]
52
+ end
53
+
54
+ # Parse compact `git log --pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%aI%x1e` output.
55
+ # Returns Array of hashes.
56
+ 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
69
+ end
70
+
71
+ # Parse compact `git log --pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e` output.
72
+ 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
84
+ end
85
+
86
+ # Resolve a file path safely within repo_path. Returns full path string or
87
+ # nil if the path escapes the root.
88
+ def resolve_path(repo_path, relative)
89
+ return nil if relative.blank?
90
+
91
+ full = File.expand_path(relative.to_s, repo_path.to_s)
92
+ full.start_with?(repo_path.to_s + "/") || full == repo_path.to_s ? full : nil
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Mbeditor
8
+ class RedmineDisabledError < StandardError
9
+ def initialize
10
+ super("Redmine integration is not enabled. Set config.mbeditor.redmine_enabled = true.")
11
+ end
12
+ end
13
+
14
+ class RedmineConfigError < StandardError; end
15
+
16
+ # Fetches a Redmine issue via the REST API.
17
+ #
18
+ # Only usable when Mbeditor.configuration.redmine_enabled is true.
19
+ #
20
+ # Returns:
21
+ # {
22
+ # "id" => Integer,
23
+ # "title" => String,
24
+ # "description" => String,
25
+ # "status" => String,
26
+ # "author" => String,
27
+ # "notes" => Array<String>
28
+ # }
29
+ class RedmineService
30
+ TIMEOUT_SECONDS = 5
31
+
32
+ attr_reader :issue_id
33
+
34
+ def initialize(issue_id:)
35
+ @issue_id = issue_id.to_s
36
+ end
37
+
38
+ def call
39
+ raise RedmineDisabledError unless Mbeditor.configuration.redmine_enabled
40
+
41
+ config = Mbeditor.configuration
42
+ raise RedmineConfigError, "redmine_url is not configured" if config.redmine_url.blank?
43
+ raise RedmineConfigError, "redmine_api_key is not configured" if config.redmine_api_key.blank?
44
+
45
+ fetch_issue(config.redmine_url, config.redmine_api_key)
46
+ end
47
+
48
+ private
49
+
50
+ def fetch_issue(base_url, api_key)
51
+ uri = URI.parse("#{base_url.chomp('/')}/issues/#{issue_id}.json")
52
+
53
+ http = Net::HTTP.new(uri.host, uri.port)
54
+ http.use_ssl = uri.scheme == "https"
55
+ http.open_timeout = TIMEOUT_SECONDS
56
+ http.read_timeout = TIMEOUT_SECONDS
57
+
58
+ request = Net::HTTP::Get.new(uri.request_uri)
59
+ request["X-Redmine-API-Key"] = api_key
60
+ request["Accept"] = "application/json"
61
+
62
+ response = http.request(request)
63
+
64
+ raise "Redmine returned HTTP #{response.code} for issue ##{issue_id}" unless response.is_a?(Net::HTTPSuccess)
65
+
66
+ data = JSON.parse(response.body)
67
+ issue = data["issue"] || {}
68
+
69
+ {
70
+ "id" => issue["id"],
71
+ "title" => issue.dig("subject"),
72
+ "description" => issue.dig("description").to_s,
73
+ "status" => issue.dig("status", "name"),
74
+ "author" => issue.dig("author", "name"),
75
+ "notes" => extract_notes(issue)
76
+ }
77
+ end
78
+
79
+ def extract_notes(issue)
80
+ journals = issue["journals"] || []
81
+ journals
82
+ .select { |j| j["notes"].to_s.present? }
83
+ .map { |j| { "author" => j.dig("user", "name"), "note" => j["notes"], "date" => j["created_on"] } }
84
+ end
85
+ end
86
+ end