mbeditor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +127 -0
- data/app/assets/javascripts/mbeditor/application.js +19 -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 +142 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +363 -0
- data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +112 -0
- data/app/assets/javascripts/mbeditor/components/FileTree.js +304 -0
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +416 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +2335 -0
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +118 -0
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +186 -0
- data/app/assets/javascripts/mbeditor/components/TabBar.js +123 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +282 -0
- data/app/assets/javascripts/mbeditor/editor_store.js +53 -0
- data/app/assets/javascripts/mbeditor/file_service.js +77 -0
- data/app/assets/javascripts/mbeditor/git_service.js +104 -0
- data/app/assets/javascripts/mbeditor/search_service.js +53 -0
- data/app/assets/javascripts/mbeditor/tab_manager.js +461 -0
- data/app/assets/stylesheets/mbeditor/application.css +705 -0
- data/app/assets/stylesheets/mbeditor/editor.css +1264 -0
- data/app/controllers/mbeditor/application_controller.rb +10 -0
- data/app/controllers/mbeditor/editors_controller.rb +695 -0
- data/app/controllers/mbeditor/git_controller.rb +188 -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 +71 -0
- data/app/services/mbeditor/git_file_history_service.rb +42 -0
- data/app/services/mbeditor/git_service.rb +82 -0
- data/app/services/mbeditor/redmine_service.rb +86 -0
- data/app/views/layouts/mbeditor/application.html.erb +71 -0
- data/app/views/mbeditor/editors/index.html.erb +1 -0
- data/config/environments/development.rb +53 -0
- data/config/initializers/assets.rb +9 -0
- data/config/routes.rb +37 -0
- data/lib/mbeditor/configuration.rb +16 -0
- data/lib/mbeditor/engine.rb +28 -0
- data/lib/mbeditor/version.rb +3 -0
- data/lib/mbeditor.rb +19 -0
- data/mbeditor.gemspec +30 -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/typespec/typespec.js +10 -0
- data/public/monaco-editor/vs/basic-languages/yaml/yaml.js +10 -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/vendor/assets/javascripts/axios.min.js +2 -0
- data/vendor/assets/javascripts/lodash.min.js +140 -0
- data/vendor/assets/javascripts/marked.min.js +6 -0
- data/vendor/assets/javascripts/minisearch.min.js +2044 -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 +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 +173 -0
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
result = GitDiffService.new(
|
|
24
|
+
repo_path: workspace_root,
|
|
25
|
+
file_path: file,
|
|
26
|
+
base_sha: params[:base].presence,
|
|
27
|
+
head_sha: params[:head].presence
|
|
28
|
+
).call
|
|
29
|
+
|
|
30
|
+
render json: result
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# GET /mbeditor/git/blame?file=<path>
|
|
36
|
+
def blame
|
|
37
|
+
file = require_file_param
|
|
38
|
+
return unless file
|
|
39
|
+
|
|
40
|
+
lines = GitBlameService.new(repo_path: workspace_root, file_path: file).call
|
|
41
|
+
render json: { lines: lines }
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# GET /mbeditor/git/file_history?file=<path>
|
|
47
|
+
def file_history
|
|
48
|
+
file = require_file_param
|
|
49
|
+
return unless file
|
|
50
|
+
|
|
51
|
+
commits = GitFileHistoryService.new(repo_path: workspace_root, file_path: file).call
|
|
52
|
+
render json: { commits: commits }
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# GET /mbeditor/git/commit_graph
|
|
58
|
+
def commit_graph
|
|
59
|
+
commits = GitCommitGraphService.new(repo_path: workspace_root).call
|
|
60
|
+
render json: { commits: commits }
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# GET /mbeditor/git/commit_detail?sha=<hash>
|
|
66
|
+
def commit_detail
|
|
67
|
+
sha = params[:sha].to_s.strip
|
|
68
|
+
return render json: { error: "sha required" }, status: :bad_request if sha.blank?
|
|
69
|
+
return render json: { error: "Invalid sha" }, status: :bad_request unless sha.match?(/\A[0-9a-fA-F]{1,40}\z/)
|
|
70
|
+
|
|
71
|
+
files_output, files_status = Open3.capture2(
|
|
72
|
+
"git", "-C", workspace_root.to_s,
|
|
73
|
+
"diff-tree", "--no-commit-id", "-r", "--name-status", sha
|
|
74
|
+
)
|
|
75
|
+
numstat_output, numstat_status = Open3.capture2(
|
|
76
|
+
"git", "-C", workspace_root.to_s,
|
|
77
|
+
"diff-tree", "--no-commit-id", "-r", "--numstat", sha
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
numstat_map = {}
|
|
81
|
+
if numstat_status.success?
|
|
82
|
+
numstat_output.lines.each do |line|
|
|
83
|
+
parts = line.strip.split("\t", 3)
|
|
84
|
+
next if parts.length < 3 || parts[0] == "-"
|
|
85
|
+
|
|
86
|
+
numstat_map[parts[2].strip] = { "added" => parts[0].to_i, "removed" => parts[1].to_i }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
files = []
|
|
91
|
+
if files_status.success?
|
|
92
|
+
files = files_output.lines.map do |line|
|
|
93
|
+
parts = line.strip.split("\t", 2)
|
|
94
|
+
next if parts.length < 2
|
|
95
|
+
file = { "status" => parts[0].strip, "path" => parts[1].strip }
|
|
96
|
+
file.merge(numstat_map.fetch(file["path"], {}))
|
|
97
|
+
end.compact
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
log_output, log_status = Open3.capture2(
|
|
101
|
+
"git", "-C", workspace_root.to_s,
|
|
102
|
+
"log", "-1", "--pretty=format:%s%x1f%an%x1f%aI", sha
|
|
103
|
+
)
|
|
104
|
+
meta = {}
|
|
105
|
+
if log_status.success?
|
|
106
|
+
fields = log_output.strip.split("\x1f", 3)
|
|
107
|
+
meta = { "title" => fields[0].to_s, "author" => fields[1].to_s, "date" => fields[2].to_s }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
render json: { sha: sha, title: meta["title"] || "", author: meta["author"] || "", date: meta["date"] || "", files: files }
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# GET /mbeditor/git/combined_diff?scope=local|branch
|
|
116
|
+
# Returns the raw unified diff text for all files in the given scope.
|
|
117
|
+
# scope=local → git diff HEAD (working tree vs HEAD)
|
|
118
|
+
# scope=branch → git diff <upstream>..HEAD (branch vs upstream)
|
|
119
|
+
def combined_diff
|
|
120
|
+
scope = params[:scope] == 'branch' ? :branch : :local
|
|
121
|
+
|
|
122
|
+
if scope == :local
|
|
123
|
+
out, status = Open3.capture2("git", "-C", workspace_root.to_s, "diff", "HEAD")
|
|
124
|
+
out = status.success? ? out : ""
|
|
125
|
+
else
|
|
126
|
+
upstream_out, upstream_status = Open3.capture2(
|
|
127
|
+
"git", "-C", workspace_root.to_s,
|
|
128
|
+
"rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"
|
|
129
|
+
)
|
|
130
|
+
upstream = upstream_status.success? ? upstream_out.strip : nil
|
|
131
|
+
if upstream.present?
|
|
132
|
+
out, status = Open3.capture2("git", "-C", workspace_root.to_s, "diff", "#{upstream}..HEAD")
|
|
133
|
+
out = status.success? ? out : ""
|
|
134
|
+
else
|
|
135
|
+
out = ""
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
render plain: out, content_type: "text/plain"
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
render plain: "", content_type: "text/plain"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# GET /mbeditor/redmine/issue/:id
|
|
145
|
+
def redmine_issue
|
|
146
|
+
unless Mbeditor.configuration.redmine_enabled
|
|
147
|
+
return render json: { error: "Redmine integration is disabled." }, status: :service_unavailable
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
result = RedmineService.new(issue_id: params[:id]).call
|
|
151
|
+
render json: result
|
|
152
|
+
rescue RedmineDisabledError => e
|
|
153
|
+
render json: { error: e.message }, status: :service_unavailable
|
|
154
|
+
rescue RedmineConfigError => e
|
|
155
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def workspace_root
|
|
163
|
+
configured = Mbeditor.configuration.workspace_root
|
|
164
|
+
configured.present? ? configured.to_s : Rails.root.to_s
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Require & validate a `file` query param, responding 400/403 on bad input.
|
|
168
|
+
# Returns the relative path string on success, or nil if already responded.
|
|
169
|
+
def require_file_param
|
|
170
|
+
raw = params[:file].to_s.strip
|
|
171
|
+
|
|
172
|
+
if raw.blank?
|
|
173
|
+
render json: { error: "file parameter is required" }, status: :bad_request
|
|
174
|
+
return nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Validate that the path stays within the workspace
|
|
178
|
+
root = workspace_root
|
|
179
|
+
full = File.expand_path(raw, root)
|
|
180
|
+
unless full.start_with?(root + "/") || full == root
|
|
181
|
+
render json: { error: "Forbidden" }, status: :forbidden
|
|
182
|
+
return nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
raw
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
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,71 @@
|
|
|
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
|
+
else
|
|
32
|
+
diff_working_tree_vs_head
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def diff_working_tree_vs_head
|
|
39
|
+
original = file_at_ref("HEAD", file_path)
|
|
40
|
+
modified = on_disk_content
|
|
41
|
+
|
|
42
|
+
{ "original" => original, "modified" => modified }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def diff_between_commits
|
|
46
|
+
original = file_at_ref(base_sha, file_path)
|
|
47
|
+
modified = file_at_ref(head_sha, file_path)
|
|
48
|
+
|
|
49
|
+
{ "original" => original, "modified" => modified }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Return the file content at a given git ref, or "" if it doesn't exist.
|
|
53
|
+
def file_at_ref(ref, path)
|
|
54
|
+
out, status = GitService.run_git(repo_path, "show", "#{ref}:#{path}")
|
|
55
|
+
return "" unless status.success?
|
|
56
|
+
|
|
57
|
+
out
|
|
58
|
+
rescue StandardError
|
|
59
|
+
""
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def on_disk_content
|
|
63
|
+
full = GitService.resolve_path(repo_path, file_path)
|
|
64
|
+
return "" unless full && File.file?(full)
|
|
65
|
+
|
|
66
|
+
File.read(full, encoding: "UTF-8", invalid: :replace, undef: :replace)
|
|
67
|
+
rescue StandardError
|
|
68
|
+
""
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
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,82 @@
|
|
|
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
|
+
# Run an arbitrary git command inside +repo_path+.
|
|
13
|
+
# Returns [stdout, Process::Status].
|
|
14
|
+
def run_git(repo_path, *args)
|
|
15
|
+
Open3.capture2("git", "-C", repo_path, *args)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Current branch name, or nil if not in a git repo.
|
|
19
|
+
def current_branch(repo_path)
|
|
20
|
+
out, status = run_git(repo_path, "branch", "--show-current")
|
|
21
|
+
status.success? ? out.strip : nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Upstream tracking branch for the current branch, e.g. "origin/main".
|
|
25
|
+
def upstream_branch(repo_path)
|
|
26
|
+
out, status = run_git(repo_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
|
27
|
+
status.success? ? out.strip : nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns [ahead_count, behind_count] relative to upstream, or [0,0].
|
|
31
|
+
def ahead_behind(repo_path, upstream)
|
|
32
|
+
return [0, 0] if upstream.blank?
|
|
33
|
+
|
|
34
|
+
out, status = run_git(repo_path, "rev-list", "--left-right", "--count", "HEAD...#{upstream}")
|
|
35
|
+
return [0, 0] unless status.success?
|
|
36
|
+
|
|
37
|
+
parts = out.strip.split("\t", 2)
|
|
38
|
+
[parts[0].to_i, parts[1].to_i]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Parse compact `git log --pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%aI%x1e` output.
|
|
42
|
+
# Returns Array of hashes.
|
|
43
|
+
def self.parse_git_log_with_parents(raw_output)
|
|
44
|
+
raw_output.split("\x1e").map do |entry|
|
|
45
|
+
fields = entry.strip.split("\x1f", 5)
|
|
46
|
+
next unless fields.length == 5
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
"hash" => fields[0],
|
|
50
|
+
"parents" => fields[1].split.reject(&:blank?),
|
|
51
|
+
"title" => fields[2],
|
|
52
|
+
"author" => fields[3],
|
|
53
|
+
"date" => fields[4]
|
|
54
|
+
}
|
|
55
|
+
end.compact
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Parse compact `git log --pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e` output.
|
|
59
|
+
def self.parse_git_log(raw_output)
|
|
60
|
+
raw_output.split("\x1e").map do |entry|
|
|
61
|
+
fields = entry.strip.split("\x1f", 4)
|
|
62
|
+
next unless fields.length == 4
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
"hash" => fields[0],
|
|
66
|
+
"title" => fields[1],
|
|
67
|
+
"author" => fields[2],
|
|
68
|
+
"date" => fields[3]
|
|
69
|
+
}
|
|
70
|
+
end.compact
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Resolve a file path safely within repo_path. Returns full path string or
|
|
74
|
+
# nil if the path escapes the root.
|
|
75
|
+
def resolve_path(repo_path, relative)
|
|
76
|
+
return nil if relative.blank?
|
|
77
|
+
|
|
78
|
+
full = File.expand_path(relative.to_s, repo_path.to_s)
|
|
79
|
+
full.start_with?(repo_path.to_s + "/") || full == repo_path.to_s ? full : nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
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
|