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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +116 -0
- data/README.md +180 -0
- data/app/assets/javascripts/mbeditor/application.js +21 -0
- data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +202 -0
- data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +71 -0
- data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +139 -0
- data/app/assets/javascripts/mbeditor/components/CommitGraph.js +65 -0
- data/app/assets/javascripts/mbeditor/components/DiffViewer.js +166 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +1139 -0
- data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +117 -0
- data/app/assets/javascripts/mbeditor/components/FileTree.js +339 -0
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +501 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +3108 -0
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +272 -0
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +186 -0
- data/app/assets/javascripts/mbeditor/components/TabBar.js +238 -0
- data/app/assets/javascripts/mbeditor/components/TestResultsPanel.js +150 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +758 -0
- data/app/assets/javascripts/mbeditor/editor_store.js +69 -0
- data/app/assets/javascripts/mbeditor/file_icon.js +30 -0
- data/app/assets/javascripts/mbeditor/file_service.js +96 -0
- data/app/assets/javascripts/mbeditor/git_service.js +104 -0
- data/app/assets/javascripts/mbeditor/search_service.js +63 -0
- data/app/assets/javascripts/mbeditor/tab_manager.js +485 -0
- data/app/assets/stylesheets/mbeditor/application.css +848 -0
- data/app/assets/stylesheets/mbeditor/editor.css +2061 -0
- data/app/controllers/mbeditor/application_controller.rb +70 -0
- data/app/controllers/mbeditor/editors_controller.rb +996 -0
- data/app/controllers/mbeditor/git_controller.rb +234 -0
- data/app/services/mbeditor/git_blame_service.rb +98 -0
- data/app/services/mbeditor/git_commit_graph_service.rb +60 -0
- data/app/services/mbeditor/git_diff_service.rb +74 -0
- data/app/services/mbeditor/git_file_history_service.rb +42 -0
- data/app/services/mbeditor/git_service.rb +95 -0
- data/app/services/mbeditor/redmine_service.rb +86 -0
- data/app/services/mbeditor/ruby_definition_service.rb +168 -0
- data/app/services/mbeditor/test_runner_service.rb +286 -0
- data/app/views/layouts/mbeditor/application.html.erb +120 -0
- data/app/views/mbeditor/editors/index.html.erb +1 -0
- data/config/initializers/assets.rb +9 -0
- data/config/routes.rb +44 -0
- data/lib/mbeditor/configuration.rb +22 -0
- data/lib/mbeditor/engine.rb +37 -0
- data/lib/mbeditor/rack/silence_ping_request.rb +56 -0
- data/lib/mbeditor/version.rb +3 -0
- data/lib/mbeditor.rb +19 -0
- data/mbeditor.gemspec +31 -0
- data/public/mbeditor-icon.svg +4 -0
- data/public/min-maps/vs/base/worker/workerMain.js.map +1 -0
- data/public/monaco-editor/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- data/public/monaco-editor/vs/base/worker/workerMain.js +31 -0
- data/public/monaco-editor/vs/basic-languages/cameligo/cameligo.js +10 -0
- data/public/monaco-editor/vs/basic-languages/css/css.js +12 -0
- data/public/monaco-editor/vs/basic-languages/dart/dart.js +10 -0
- data/public/monaco-editor/vs/basic-languages/flow9/flow9.js +10 -0
- data/public/monaco-editor/vs/basic-languages/go/go.js +10 -0
- data/public/monaco-editor/vs/basic-languages/handlebars/handlebars.js +440 -0
- data/public/monaco-editor/vs/basic-languages/javascript/javascript.js +10 -0
- data/public/monaco-editor/vs/basic-languages/markdown/markdown.js +10 -0
- data/public/monaco-editor/vs/basic-languages/msdax/msdax.js +10 -0
- data/public/monaco-editor/vs/basic-languages/postiats/postiats.js +10 -0
- data/public/monaco-editor/vs/basic-languages/pug/pug.js +412 -0
- data/public/monaco-editor/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- data/public/monaco-editor/vs/basic-languages/ruby/ruby.js +10 -0
- data/public/monaco-editor/vs/basic-languages/sb/sb.js +10 -0
- data/public/monaco-editor/vs/basic-languages/shell/shell.js +41 -0
- data/public/monaco-editor/vs/basic-languages/typescript/typescript.js +10 -0
- data/public/monaco-editor/vs/basic-languages/typespec/typespec.js +10 -0
- data/public/monaco-editor/vs/basic-languages/yaml/yaml.js +10 -0
- data/public/monaco-editor/vs/editor/editor.api.js +6 -0
- data/public/monaco-editor/vs/editor/editor.main.css +8 -0
- data/public/monaco-editor/vs/editor/editor.main.js +797 -0
- data/public/monaco-editor/vs/language/typescript/tsMode.js +20 -0
- data/public/monaco-editor/vs/language/typescript/tsWorker.js +51328 -0
- data/public/monaco-editor/vs/loader.js +10 -0
- data/public/monaco-editor/vs/nls.messages.de.js +20 -0
- data/public/monaco-editor/vs/nls.messages.es.js +20 -0
- data/public/monaco-editor/vs/nls.messages.fr.js +18 -0
- data/public/monaco-editor/vs/nls.messages.it.js +18 -0
- data/public/monaco-editor/vs/nls.messages.ja.js +20 -0
- data/public/monaco-editor/vs/nls.messages.ko.js +18 -0
- data/public/monaco-editor/vs/nls.messages.ru.js +20 -0
- data/public/monaco-editor/vs/nls.messages.zh-cn.js +20 -0
- data/public/monaco-editor/vs/nls.messages.zh-tw.js +18 -0
- data/public/monaco_worker.js +5 -0
- data/public/sw.js +8 -0
- data/public/ts_worker.js +5 -0
- data/vendor/assets/javascripts/axios.min.js +5 -0
- data/vendor/assets/javascripts/emmet.js +5452 -0
- data/vendor/assets/javascripts/lodash.min.js +136 -0
- data/vendor/assets/javascripts/marked.min.js +6 -0
- data/vendor/assets/javascripts/minisearch.min.js +2044 -0
- data/vendor/assets/javascripts/monaco-themes-bundle.js +10 -0
- data/vendor/assets/javascripts/monaco-vim.js +9867 -0
- data/vendor/assets/javascripts/prettier-plugin-babel.js +16 -0
- data/vendor/assets/javascripts/prettier-plugin-estree.js +35 -0
- data/vendor/assets/javascripts/prettier-plugin-html.js +19 -0
- data/vendor/assets/javascripts/prettier-plugin-markdown.js +59 -0
- data/vendor/assets/javascripts/prettier-plugin-postcss.js +52 -0
- data/vendor/assets/javascripts/prettier-standalone.js +37 -0
- data/vendor/assets/javascripts/react-dom.min.js +267 -0
- data/vendor/assets/javascripts/react.min.js +31 -0
- data/vendor/assets/stylesheets/fontawesome.min.css.erb +9 -0
- data/vendor/assets/webfonts/fa-brands-400.woff2 +0 -0
- data/vendor/assets/webfonts/fa-regular-400.woff2 +0 -0
- data/vendor/assets/webfonts/fa-solid-900.woff2 +0 -0
- 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
|