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.
Files changed (94) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +127 -0
  3. data/app/assets/javascripts/mbeditor/application.js +19 -0
  4. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +202 -0
  5. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +71 -0
  6. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +139 -0
  7. data/app/assets/javascripts/mbeditor/components/CommitGraph.js +65 -0
  8. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +142 -0
  9. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +363 -0
  10. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +112 -0
  11. data/app/assets/javascripts/mbeditor/components/FileTree.js +304 -0
  12. data/app/assets/javascripts/mbeditor/components/GitPanel.js +416 -0
  13. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +2335 -0
  14. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +118 -0
  15. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +186 -0
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +123 -0
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +282 -0
  18. data/app/assets/javascripts/mbeditor/editor_store.js +53 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +77 -0
  20. data/app/assets/javascripts/mbeditor/git_service.js +104 -0
  21. data/app/assets/javascripts/mbeditor/search_service.js +53 -0
  22. data/app/assets/javascripts/mbeditor/tab_manager.js +461 -0
  23. data/app/assets/stylesheets/mbeditor/application.css +705 -0
  24. data/app/assets/stylesheets/mbeditor/editor.css +1264 -0
  25. data/app/controllers/mbeditor/application_controller.rb +10 -0
  26. data/app/controllers/mbeditor/editors_controller.rb +695 -0
  27. data/app/controllers/mbeditor/git_controller.rb +188 -0
  28. data/app/services/mbeditor/git_blame_service.rb +98 -0
  29. data/app/services/mbeditor/git_commit_graph_service.rb +60 -0
  30. data/app/services/mbeditor/git_diff_service.rb +71 -0
  31. data/app/services/mbeditor/git_file_history_service.rb +42 -0
  32. data/app/services/mbeditor/git_service.rb +82 -0
  33. data/app/services/mbeditor/redmine_service.rb +86 -0
  34. data/app/views/layouts/mbeditor/application.html.erb +71 -0
  35. data/app/views/mbeditor/editors/index.html.erb +1 -0
  36. data/config/environments/development.rb +53 -0
  37. data/config/initializers/assets.rb +9 -0
  38. data/config/routes.rb +37 -0
  39. data/lib/mbeditor/configuration.rb +16 -0
  40. data/lib/mbeditor/engine.rb +28 -0
  41. data/lib/mbeditor/version.rb +3 -0
  42. data/lib/mbeditor.rb +19 -0
  43. data/mbeditor.gemspec +30 -0
  44. data/public/min-maps/vs/base/worker/workerMain.js.map +1 -0
  45. data/public/monaco-editor/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  46. data/public/monaco-editor/vs/base/worker/workerMain.js +31 -0
  47. data/public/monaco-editor/vs/basic-languages/cameligo/cameligo.js +10 -0
  48. data/public/monaco-editor/vs/basic-languages/css/css.js +12 -0
  49. data/public/monaco-editor/vs/basic-languages/dart/dart.js +10 -0
  50. data/public/monaco-editor/vs/basic-languages/flow9/flow9.js +10 -0
  51. data/public/monaco-editor/vs/basic-languages/go/go.js +10 -0
  52. data/public/monaco-editor/vs/basic-languages/handlebars/handlebars.js +440 -0
  53. data/public/monaco-editor/vs/basic-languages/javascript/javascript.js +10 -0
  54. data/public/monaco-editor/vs/basic-languages/markdown/markdown.js +10 -0
  55. data/public/monaco-editor/vs/basic-languages/msdax/msdax.js +10 -0
  56. data/public/monaco-editor/vs/basic-languages/postiats/postiats.js +10 -0
  57. data/public/monaco-editor/vs/basic-languages/pug/pug.js +412 -0
  58. data/public/monaco-editor/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  59. data/public/monaco-editor/vs/basic-languages/ruby/ruby.js +10 -0
  60. data/public/monaco-editor/vs/basic-languages/sb/sb.js +10 -0
  61. data/public/monaco-editor/vs/basic-languages/typespec/typespec.js +10 -0
  62. data/public/monaco-editor/vs/basic-languages/yaml/yaml.js +10 -0
  63. data/public/monaco-editor/vs/editor/editor.main.css +8 -0
  64. data/public/monaco-editor/vs/editor/editor.main.js +797 -0
  65. data/public/monaco-editor/vs/language/typescript/tsMode.js +20 -0
  66. data/public/monaco-editor/vs/language/typescript/tsWorker.js +51328 -0
  67. data/public/monaco-editor/vs/loader.js +10 -0
  68. data/public/monaco-editor/vs/nls.messages.de.js +20 -0
  69. data/public/monaco-editor/vs/nls.messages.es.js +20 -0
  70. data/public/monaco-editor/vs/nls.messages.fr.js +18 -0
  71. data/public/monaco-editor/vs/nls.messages.it.js +18 -0
  72. data/public/monaco-editor/vs/nls.messages.ja.js +20 -0
  73. data/public/monaco-editor/vs/nls.messages.ko.js +18 -0
  74. data/public/monaco-editor/vs/nls.messages.ru.js +20 -0
  75. data/public/monaco-editor/vs/nls.messages.zh-cn.js +20 -0
  76. data/public/monaco-editor/vs/nls.messages.zh-tw.js +18 -0
  77. data/public/monaco_worker.js +5 -0
  78. data/vendor/assets/javascripts/axios.min.js +2 -0
  79. data/vendor/assets/javascripts/lodash.min.js +140 -0
  80. data/vendor/assets/javascripts/marked.min.js +6 -0
  81. data/vendor/assets/javascripts/minisearch.min.js +2044 -0
  82. data/vendor/assets/javascripts/prettier-plugin-babel.js +16 -0
  83. data/vendor/assets/javascripts/prettier-plugin-estree.js +35 -0
  84. data/vendor/assets/javascripts/prettier-plugin-html.js +19 -0
  85. data/vendor/assets/javascripts/prettier-plugin-markdown.js +59 -0
  86. data/vendor/assets/javascripts/prettier-plugin-postcss.js +52 -0
  87. data/vendor/assets/javascripts/prettier-standalone.js +37 -0
  88. data/vendor/assets/javascripts/react-dom.min.js +267 -0
  89. data/vendor/assets/javascripts/react.min.js +31 -0
  90. data/vendor/assets/stylesheets/fontawesome.min.css +9 -0
  91. data/vendor/assets/webfonts/fa-brands-400.woff2 +0 -0
  92. data/vendor/assets/webfonts/fa-regular-400.woff2 +0 -0
  93. data/vendor/assets/webfonts/fa-solid-900.woff2 +0 -0
  94. 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