siGit Code bakes in this MCP server but could not touch repository
workflows through it. Four new tools close that: list_issues (state
filter plus a title/body search), get_issue (body and comments),
create_issue (through a new IssueService that mirrors
PullRequestService and shares the per-repo number space), and
get_pull_request (body, comments, and the three-dot diff against the
base branch, capped at 100 kB with an explicit truncation note, and a
graceful note when a branch is gone).
comment_on_pull_request was deliberately not added: issues and pull
requests share one number space and the existing add_issue_comment
already covers both, so a duplicate tool would only confuse the model
consumer. Authorization reuses the existing visibility scopes; the
registry, result envelope, and in-band error style follow the current
tools exactly, and the internal MCP spec doc is updated to match.
Seto Elkahfi committedJul 5, 2026 at 08:38 UTC941b556723a9f72e4158551526e6514ac0c7da22
9 files changed+545-35
.agents/specs/mcp-server.md
+77-24
index c2af613..491c112 100644--- a/.agents/specs/mcp-server.md+++ b/.agents/specs/mcp-server.md@@ -1,6 +1,6 @@ --- name: mcp-server-description: "Specification for the siGit MCP server at /api/v1/mcp. A stateless Streamable-HTTP JSON-RPC endpoint that exposes siGit repositories to MCP clients (Claude Code and others) as six tools: list_repositories, get_file_contents, search_code, list_pull_requests, create_pull_request, add_issue_comment. Covers transport, auth, the protocol method set, the tool contracts, the pull request / issue / comment data model, authorization, and acceptance criteria."+description: "Specification for the siGit MCP server at /api/v1/mcp. A stateless Streamable-HTTP JSON-RPC endpoint that exposes siGit repositories to MCP clients (Claude Code and others) as ten tools: list_repositories, get_file_contents, search_code, list_issues, get_issue, create_issue, list_pull_requests, get_pull_request, create_pull_request, add_issue_comment. Covers transport, auth, the protocol method set, the tool contracts, the pull request / issue / comment data model, authorization, and acceptance criteria." status: implemented implements: - app/controllers/api/v1/mcp_controller.rb@@ -8,11 +8,13 @@ implements: - app/services/mcp/tools.rb - app/services/mcp/tool_error.rb - app/models/{pull_request,issue,comment,repository}.rb- - app/services/{pull_request_service,comment_service}.rb+ - app/services/{pull_request_service,issue_service,comment_service}.rb - config/routes.rb verifies: - spec/requests/api/v1/mcp_spec.rb+ - spec/requests/api/v1/mcp_tools_spec.rb - spec/services/pull_request_service_spec.rb+ - spec/services/issue_service_spec.rb - spec/services/comment_service_spec.rb ---@@ -142,7 +144,7 @@ so adding a tool is a one-line change (append the class to `all`). Each tool declares `tool_name`, `description`, `input_schema` (JSON Schema surfaced to the model), and a `call`.-The six tools, in registry order:+The ten tools, in registry order: ### list_repositories@@ -177,6 +179,35 @@ The six tools, in registry order: parses `<ref>:<path>:<line>:<text>`. - **Output:** array of `{ path, line, snippet }`, capped at `limit`.+### list_issues++- **Purpose:** list issues, optionally by state or a text query.+- **Input (required):** `repo`. Optional `state` in `open|closed|all` (default+ `open`), `query` (case-insensitive substring of the issue title or body).+- **Behaviour:** `repo.issues`, filtered by state unless `all`, `ILIKE` filter+ on title or body when `query` is given, ordered by `number` descending,+ limited to 50.+- **Output:** array of `{ number, title, state, author, url }`.++### get_issue++- **Purpose:** read one issue in full, including its comments.+- **Input (required):** `repo`, `number`.+- **Behaviour:** resolve the repo (read scope), find the issue by number.+- **Output:** `{ number, title, state, author, body, url, comments }` where+ `comments` is an array of `{ author, body, created_at }`, oldest first.+- **Errors (in band):** no issue with that number.++### create_issue++- **Purpose:** open a new issue.+- **Input (required):** `repo`, `title`. Optional `body` (Markdown).+- **Behaviour:** routed through `IssueService.create` so per-repo numbering+ (shared with pull requests) runs identically to any other caller. Anyone who+ can read the repo may open an issue on it, as with comments.+- **Output:** `{ number, state, url }`.+- **Errors (in band):** blank title; any model validation failure.+ ### list_pull_requests - **Purpose:** list pull requests, optionally by state.@@ -186,6 +217,20 @@ The six tools, in registry order: `number` descending, limited to 50. - **Output:** array of `{ number, title, state, head, base, url }`.+### get_pull_request++- **Purpose:** read one pull request in full, including its comments and diff.+- **Input (required):** `repo`, `number`.+- **Behaviour:** resolve the repo (read scope), find the PR by number, then+ `Repository#diff_between(base_ref, head_ref)` -> `GitRepositoryService.diff`+ (three-dot `git diff base...head`, the range a pull request shows).+- **Output:** `{ number, title, state, author, head, base, body, url, comments,+ diff }`. The diff is capped at 100 kB; past the cap the result carries+ `diff_truncated: true` and a `diff_note`. When a ref no longer exists (e.g.+ the head branch of a merged PR was deleted), `diff` is null with a+ `diff_note` saying so.+- **Errors (in band):** no pull request with that number.+ ### create_pull_request - **Purpose:** open a pull request from a head branch into a base branch.@@ -224,31 +269,35 @@ following were added to back the tools. and a user (author). Column: `body` (present). `html_url` anchors to the parent. - **Shared numbering.** Pull requests and issues draw from one per-repository number space (as on GitHub), so a number maps to exactly one record. The next- number is `max(pull_requests.number, issues.number) + 1`, computed under a- repository row lock during creation.+ number is `max(pull_requests.number, issues.number) + 1`, computed by+ `Repository#next_issue_number` under a repository row lock during creation;+ both `PullRequestService` and `IssueService` allocate through it. ## Authorization-- **Read** (list_repositories, get_file_contents, search_code, list_pull_requests,- resolving the repo for any tool): `Repository.visible_to(user)`, which returns- public repositories plus the caller's own. Private repositories of other users- are never resolvable, so they cannot be read or mutated.+- **Read** (list_repositories, get_file_contents, search_code, list_issues,+ get_issue, list_pull_requests, get_pull_request, resolving the repo for any+ tool): `Repository.visible_to(user)`, which returns public repositories plus+ the caller's own. Private repositories of other users are never resolvable,+ so they cannot be read or mutated. - **Write** (create_pull_request): `Repository#writable_by?(user)`. siGit repos have a single owner and no collaborators yet, so write equals ownership. A non-owner attempt returns an in-band `isError`.-- **Comment** (add_issue_comment): requires read access to the repo (enforced by- repo resolution). The author is always the authenticated user.+- **Comment and issue creation** (add_issue_comment, create_issue): require read+ access to the repo (enforced by repo resolution). The author is always the+ authenticated user.-Mutations go through the service objects (`PullRequestService`, `CommentService`),-never directly through `create!` from the tool, so that the same validations and-authorization run for the MCP path as for any future web UI.+Mutations go through the service objects (`PullRequestService`, `IssueService`,+`CommentService`), never directly through `create!` from the tool, so that the+same validations and authorization run for the MCP path as for any future web UI. ## Supporting layers - **Git read layer.** `Repository#blob_at(ref, path)` delegates to `GitRepositoryService.file_content` (a `git show <ref>:<path>`). `Repository#search_code` delegates to `GitRepositoryService.search_code`- (`git grep`).+ (`git grep`). `Repository#diff_between(base, head)` delegates to+ `GitRepositoryService.diff` (`git diff base...head`). - **URLs.** `Repository#html_url`, `PullRequest#html_url`, `Issue#html_url`, and `Comment#html_url` build absolute links from `Repository.site_url`, which reads `SIGITSI_URL` (default `https://sigit.si`). This is needed because tools have no@@ -258,7 +307,7 @@ authorization run for the MCP path as for any future web UI. 1. `claude mcp add --transport http sigit https://sigit.si/api/v1/mcp --header "Authorization: Bearer <token>"` connects.-2. `tools/list` returns all six tools.+2. `tools/list` returns all ten tools. 3. `list_repositories` and `get_file_contents` work end to end against a real repository. 4. An unauthenticated request returns a JSON-RPC 401 with a `WWW-Authenticate`@@ -268,12 +317,16 @@ authorization run for the MCP path as for any future web UI. ## Verification - Request specs (`spec/requests/api/v1/mcp_spec.rb`): initialize handshake,- `tools/list` shows all six, a successful `tools/call`, a notification answered+ `tools/list` shows all ten, a successful `tools/call`, a notification answered with 202, a 401 with the challenge, and a tool returning `isError`.+- Request specs (`spec/requests/api/v1/mcp_tools_spec.rb`): the issue and pull+ request tools end to end against a real bare repository — happy paths+ (including the real three-dot diff), diff truncation, missing-branch diffs,+ unknown numbers, an invisible repository, and missing required arguments. - Service specs (`spec/services/pull_request_service_spec.rb`,- `comment_service_spec.rb`): authorization, branch validation, head-differs-from-- base, shared numbering, comment resolution across issues and pull requests,- blank-body rejection.+ `issue_service_spec.rb`, `comment_service_spec.rb`): authorization, branch+ validation, head-differs-from-base, shared numbering, comment resolution+ across issues and pull requests, blank-title and blank-body rejection. ## Out of scope (current) and natural extensions@@ -283,8 +336,8 @@ authorization run for the MCP path as for any future web UI. - Webhooks on pull request / issue / comment creation. None exist in the app yet; when added, they run automatically because mutations go through the service objects.-- Further tools, each a one-line registry addition: `get_pull_request` (with- diff), `list_branches`, `list_commits`, `create_issue`, `list_issues`.+- Further tools, each a one-line registry addition: `list_branches`,+ `list_commits`, `close_issue`, `merge_pull_request`. ## Key files@@ -292,8 +345,8 @@ authorization run for the MCP path as for any future web UI. - `app/services/mcp/server.rb`: protocol dispatch. - `app/services/mcp/tools.rb`: tool registry and tool implementations. - `app/services/mcp/tool_error.rb`: in-band tool failure type.-- `app/services/pull_request_service.rb`, `app/services/comment_service.rb`:- mutation services.+- `app/services/pull_request_service.rb`, `app/services/issue_service.rb`,+ `app/services/comment_service.rb`: mutation services. - `app/models/pull_request.rb`, `issue.rb`, `comment.rb`, and the additions to `repository.rb` and `git_repository_service.rb`. - `config/routes.rb`: `post`/`get api/v1/mcp`.
app/models/repository.rb
+16
index d60dbcd..82a8bfa 100644--- a/app/models/repository.rb+++ b/app/models/repository.rb@@ -62,6 +62,22 @@ class Repository < ApplicationRecord GitRepositoryService.search_code(disk_path, ref || default_branch, query, limit: limit) end+ # Unified diff of +head+ against its merge base with +base+ (what a pull+ # request shows). Returns the diff String ("" when the refs are identical),+ # or nil when either ref is unknown — e.g. the head branch of a merged pull+ # request has been deleted.+ def diff_between(base, head)+ GitRepositoryService.diff(disk_path, base, head)+ end++ # Next number in the shared issue / pull request number space. Issues and+ # pull requests draw from one per-repository sequence (as on GitHub), so a+ # number resolves to exactly one record. Call inside a `with_lock` block+ # when allocating.+ def next_issue_number+ [ pull_requests.maximum(:number) || 0, issues.maximum(:number) || 0 ].max + 1+ end+ def git_path disk_path end
app/services/git_repository_service.rb
+10
index 536b557..b895c60 100644--- a/app/services/git_repository_service.rb+++ b/app/services/git_repository_service.rb@@ -198,6 +198,16 @@ class GitRepositoryService end end+ # Unified diff of +head+ against its merge base with +base+ (three-dot+ # `git diff base...head`, the same range a pull request shows). Returns the+ # diff String ("" when the refs are identical), or nil when either ref is+ # unknown or the repository is missing.+ def self.diff(path, base, head)+ out, _err, status = Open3.capture3("git", "--git-dir", path, "diff", "#{base}...#{head}")+ return nil unless status.success?+ out+ end+ def self.branch_exists?(path, branch) _out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", branch) status.success?
app/services/issue_service.rb
+24
new file mode 100644index 0000000..a260277--- /dev/null+++ b/app/services/issue_service.rb@@ -0,0 +1,24 @@+# frozen_string_literal: true++# Creates issues. All issue creation — web UI or MCP — goes through here so+# per-repo numbering (shared with pull requests) behaves identically across+# callers.+class IssueService+ class Error < StandardError; end++ # Opens an issue on +repository+ authored by +author+. Anyone who can read a+ # repository may open an issue on it, so read access is enforced by the+ # caller (the MCP layer only resolves repos the user can read), as with+ # comments. Raises ActiveRecord::RecordInvalid on validation failure.+ def self.create(repository:, author:, title:, body: nil)+ repository.with_lock do+ repository.issues.create!(+ user: author,+ number: repository.next_issue_number,+ title: title,+ body: body.to_s,+ state: "open"+ )+ end+ end+end
app/services/mcp/tools.rb
+139
index cc5d2a4..4ef5cb9 100644--- a/app/services/mcp/tools.rb+++ b/app/services/mcp/tools.rb@@ -19,7 +19,11 @@ module Mcp ListRepositories, GetFileContents, SearchCode,+ ListIssues,+ GetIssue,+ CreateIssue, ListPullRequests,+ GetPullRequest, CreatePullRequest, AddIssueComment ].freeze@@ -71,6 +75,14 @@ module Mcp .find_by(users: { username: owner }, repositories: { name: name }) repo || raise(Mcp::ToolError, "Repository not found or not accessible: #{full_name}") end++ # Comments serialized oldest-first — the shape both get_issue and+ # get_pull_request return.+ def comments_for(commentable)+ commentable.comments.includes(:user).order(:created_at).map do |c|+ { "author" => c.author.username, "body" => c.body, "created_at" => c.created_at.iso8601 }+ end+ end end # ----------------------------------------------------------------------- #@@ -154,6 +166,88 @@ module Mcp end end+ class ListIssues < Base+ name! "list_issues"+ describe "List issues in a repository (requires repo), optionally filtered by state or a title/body search."+ input_schema(+ "type" => "object",+ "required" => %w[repo],+ "properties" => {+ "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },+ "query" => { "type" => "string", "description" => "Optional case-insensitive substring of the issue title or body." },+ "state" => { "type" => "string", "enum" => %w[open closed all], "default" => "open" }+ }+ )++ def call(args)+ repo = find_repo!(require_arg(args, "repo"))+ state = args["state"].presence || "open"+ scope = state == "all" ? repo.issues : repo.issues.where(state: state)+ if args["query"].present?+ scope = scope.where("issues.title ILIKE :q OR issues.body ILIKE :q", q: "%#{args['query']}%")+ end+ scope.includes(:user).order(number: :desc).limit(50).map do |issue|+ { "number" => issue.number, "title" => issue.title, "state" => issue.state,+ "author" => issue.author.username, "url" => issue.html_url }+ end+ end+ end++ class GetIssue < Base+ name! "get_issue"+ describe "Fetch one issue by number (requires repo and number), including its body and comments."+ input_schema(+ "type" => "object",+ "required" => %w[repo number],+ "properties" => {+ "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },+ "number" => { "type" => "integer", "description" => "Issue number." }+ }+ )++ def call(args)+ repo = find_repo!(require_arg(args, "repo"))+ number = require_arg(args, "number")+ issue = repo.issues.find_by(number: number)+ raise Mcp::ToolError, "No issue ##{number} in #{repo.full_name}." unless issue++ { "number" => issue.number, "title" => issue.title, "state" => issue.state,+ "author" => issue.author.username, "body" => issue.body, "url" => issue.html_url,+ "comments" => comments_for(issue) }+ end+ end++ class CreateIssue < Base+ name! "create_issue"+ describe "Open a new issue in a repository (requires repo and title)."+ input_schema(+ "type" => "object",+ "required" => %w[repo title],+ "properties" => {+ "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },+ "title" => { "type" => "string" },+ "body" => { "type" => "string", "description" => "Issue description (Markdown)." }+ }+ )++ def call(args)+ repo = find_repo!(require_arg(args, "repo"))+ # Route through the service so per-repo numbering (shared with pull+ # requests) runs exactly as it does for any other caller.+ issue = IssueService.create(+ repository: repo,+ author: current_user,+ title: require_arg(args, "title"),+ body: args["body"]+ )+ { "number" => issue.number, "state" => issue.state, "url" => issue.html_url }+ rescue IssueService::Error => e+ raise Mcp::ToolError, e.message+ rescue ActiveRecord::RecordInvalid => e+ raise Mcp::ToolError, e.record.errors.full_messages.to_sentence+ end+ end+ class ListPullRequests < Base name! "list_pull_requests" describe "List pull requests in a repository, optionally filtered by state."@@ -177,6 +271,51 @@ module Mcp end end+ class GetPullRequest < Base+ # Unified diffs are included up to this many bytes so one large pull+ # request cannot blow out the model's context window.+ DIFF_MAX_BYTES = 100_000++ name! "get_pull_request"+ describe "Fetch one pull request by number (requires repo and number), " \+ "including its body, comments, and unified diff (truncated past #{DIFF_MAX_BYTES / 1000} kB)."+ input_schema(+ "type" => "object",+ "required" => %w[repo number],+ "properties" => {+ "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },+ "number" => { "type" => "integer", "description" => "Pull request number." }+ }+ )++ def call(args)+ repo = find_repo!(require_arg(args, "repo"))+ number = require_arg(args, "number")+ pr = repo.pull_requests.find_by(number: number)+ raise Mcp::ToolError, "No pull request ##{number} in #{repo.full_name}." unless pr++ { "number" => pr.number, "title" => pr.title, "state" => pr.state,+ "author" => pr.author.username, "head" => pr.head_ref, "base" => pr.base_ref,+ "body" => pr.body, "url" => pr.html_url,+ "comments" => comments_for(pr) }.merge(diff_fields(repo, pr))+ end++ private++ # The three-dot diff of head against base, capped at DIFF_MAX_BYTES.+ # nil with a note when a ref no longer exists (e.g. the head branch of a+ # merged pull request was deleted).+ def diff_fields(repo, pr)+ diff = repo.diff_between(pr.base_ref, pr.head_ref)+ return { "diff" => nil, "diff_note" => "Diff unavailable: a branch no longer exists." } if diff.nil?+ return { "diff" => diff } if diff.bytesize <= DIFF_MAX_BYTES++ { "diff" => diff.byteslice(0, DIFF_MAX_BYTES).scrub(""),+ "diff_truncated" => true,+ "diff_note" => "Diff truncated to the first #{DIFF_MAX_BYTES} bytes." }+ end+ end+ class CreatePullRequest < Base name! "create_pull_request" describe "Open a pull request from a head branch into a base branch."
app/services/pull_request_service.rb
+3-9
index 39fdc0c..7ce5cc1 100644--- a/app/services/pull_request_service.rb+++ b/app/services/pull_request_service.rb@@ -22,7 +22,9 @@ class PullRequestService repository.with_lock do repository.pull_requests.create!( user: author,- number: next_number(repository),+ # Issues and pull requests share one per-repo number space (as on+ # GitHub); Repository#next_issue_number is the single allocator.+ number: repository.next_issue_number, title: title, head_ref: head, base_ref: base,@@ -31,12 +33,4 @@ class PullRequestService ) end end-- # Issues and pull requests share one per-repo number space (as on GitHub), so a- # number resolves to exactly one of them. Call inside a repository row lock.- def self.next_number(repository)- [ repository.pull_requests.maximum(:number) || 0,- repository.issues.maximum(:number) || 0 ].max + 1- end- private_class_method :next_number end
spec/requests/api/v1/mcp_spec.rb
+3-2
index 56628a5..15d2378 100644--- a/spec/requests/api/v1/mcp_spec.rb+++ b/spec/requests/api/v1/mcp_spec.rb@@ -61,13 +61,14 @@ RSpec.describe "Api::V1::Mcp", type: :request do end describe "tools/list" do- it "lists all six tools" do+ it "lists all ten tools" do tools = rpc({ "jsonrpc" => "2.0", "id" => 2, "method" => "tools/list" })["result"]["tools"] names = tools.map { |t| t["name"] } expect(names).to contain_exactly( "list_repositories", "get_file_contents", "search_code",- "list_pull_requests", "create_pull_request", "add_issue_comment"+ "list_issues", "get_issue", "create_issue",+ "list_pull_requests", "get_pull_request", "create_pull_request", "add_issue_comment" ) expect(tools).to all(include("description", "inputSchema")) end
spec/requests/api/v1/mcp_tools_spec.rb
+232
new file mode 100644index 0000000..a932c64--- /dev/null+++ b/spec/requests/api/v1/mcp_tools_spec.rb@@ -0,0 +1,232 @@+# frozen_string_literal: true++require "rails_helper"+require "tmpdir"+require "fileutils"++# Request specs for the repo-workflow MCP tools (issues and pull requests).+# Each tool is exercised end to end through POST /api/v1/mcp: the happy path,+# a repository the caller cannot see, and missing required arguments — the+# failures reported in-band as isError, never as protocol errors.+RSpec.describe "Api::V1::Mcp repo workflow tools", type: :request do+ around do |example|+ Dir.mktmpdir("mcp-tools-spec") do |tmp|+ @repo_dir = File.join(tmp, "demo.git")+ build_repo_with_feature_branch(@repo_dir)+ example.run+ end+ end++ let(:user) do+ User.create!(smbcloud_id: 4243, email: "mcp-tools-tester@example.com", username: "mcptools")+ end+ let(:stranger) do+ User.create!(smbcloud_id: 4244, email: "stranger@example.com", username: "stranger")+ end++ let!(:repository) do+ user.repositories.create!(+ name: "demo", kind: "code", default_branch: "main", disk_path: @repo_dir+ )+ end++ # A private repository owned by someone else: invisible to the caller, so+ # every tool must answer "not found or not accessible" rather than leak it.+ let!(:private_repo) do+ stranger.repositories.create!(+ name: "secret", kind: "code", default_branch: "main", is_private: true,+ disk_path: GitRepositoryService.repo_path("stranger", "secret")+ )+ end++ let(:token) { "valid-access-token" }+ let(:auth_headers) do+ { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }+ end++ before do+ allow_any_instance_of(Api::V1::McpController)+ .to receive(:resolve_user) { |_controller, presented| presented == token ? user : nil }+ end++ def call_tool(name, arguments)+ post "/api/v1/mcp",+ params: { "jsonrpc" => "2.0", "id" => 1, "method" => "tools/call",+ "params" => { "name" => name, "arguments" => arguments } }.to_json,+ headers: auth_headers+ response.parsed_body["result"]+ end++ # Successful tool output is a JSON document in the text content block.+ def payload(result)+ expect(result["isError"]).to be(false)+ JSON.parse(result["content"].first["text"])+ end++ def error_text(result)+ expect(result["isError"]).to be(true)+ result["content"].first["text"]+ end++ describe "list_issues" do+ before do+ repository.issues.create!(user: user, number: 1, title: "Auth bug", body: "Login fails on Safari")+ repository.issues.create!(user: user, number: 2, title: "Update docs", state: "closed")+ end++ it "lists open issues by default" do+ issues = payload(call_tool("list_issues", { "repo" => "mcptools/demo" }))+ expect(issues.map { |i| i["number"] }).to eq([ 1 ])+ expect(issues.first).to include("title" => "Auth bug", "state" => "open", "author" => "mcptools")+ end++ it "filters by state and by a title/body query" do+ issues = payload(call_tool("list_issues",+ { "repo" => "mcptools/demo", "state" => "all", "query" => "docs" }))+ expect(issues.map { |i| i["number"] }).to eq([ 2 ])+ end++ it "rejects a repository the caller cannot see" do+ result = call_tool("list_issues", { "repo" => "stranger/secret" })+ expect(error_text(result)).to match(/not found or not accessible/i)+ end++ it "rejects a call missing the repo argument" do+ result = call_tool("list_issues", {})+ expect(error_text(result)).to match(/Missing required argument: repo/)+ end+ end++ describe "get_issue" do+ let!(:issue) do+ repository.issues.create!(user: user, number: 1, title: "Auth bug", body: "Login fails")+ end++ it "returns the issue with its body and comments" do+ issue.comments.create!(user: stranger, body: "Reproduced on my machine")++ data = payload(call_tool("get_issue", { "repo" => "mcptools/demo", "number" => 1 }))+ expect(data).to include("number" => 1, "title" => "Auth bug", "state" => "open",+ "author" => "mcptools", "body" => "Login fails")+ expect(data["comments"]).to contain_exactly(+ a_hash_including("author" => "stranger", "body" => "Reproduced on my machine")+ )+ end++ it "reports an unknown issue number in-band" do+ result = call_tool("get_issue", { "repo" => "mcptools/demo", "number" => 99 })+ expect(error_text(result)).to match(/No issue #99/)+ end++ it "rejects a repository the caller cannot see" do+ result = call_tool("get_issue", { "repo" => "stranger/secret", "number" => 1 })+ expect(error_text(result)).to match(/not found or not accessible/i)+ end++ it "rejects a call missing the number argument" do+ result = call_tool("get_issue", { "repo" => "mcptools/demo" })+ expect(error_text(result)).to match(/Missing required argument: number/)+ end+ end++ describe "create_issue" do+ it "opens an issue numbered in the shared issue/PR space" do+ repository.pull_requests.create!(+ user: user, number: 1, title: "PR", head_ref: "feature", base_ref: "main"+ )++ data = payload(call_tool("create_issue",+ { "repo" => "mcptools/demo", "title" => "Flaky spec", "body" => "Fails on CI" }))+ expect(data).to include("number" => 2, "state" => "open")++ issue = repository.issues.find_by(number: 2)+ expect(issue.title).to eq("Flaky spec")+ expect(issue.body).to eq("Fails on CI")+ expect(issue.author).to eq(user)+ end++ it "rejects a repository the caller cannot see" do+ result = call_tool("create_issue", { "repo" => "stranger/secret", "title" => "X" })+ expect(error_text(result)).to match(/not found or not accessible/i)+ end++ it "rejects a call missing the title argument" do+ result = call_tool("create_issue", { "repo" => "mcptools/demo" })+ expect(error_text(result)).to match(/Missing required argument: title/)+ end+ end++ describe "get_pull_request" do+ let!(:pull_request) do+ repository.pull_requests.create!(+ user: user, number: 1, title: "Add feature", body: "Adds the feature",+ head_ref: "feature", base_ref: "main"+ )+ end++ it "returns the pull request with its comments and unified diff" do+ pull_request.comments.create!(user: stranger, body: "LGTM")++ data = payload(call_tool("get_pull_request", { "repo" => "mcptools/demo", "number" => 1 }))+ expect(data).to include("number" => 1, "title" => "Add feature", "state" => "open",+ "head" => "feature", "base" => "main", "author" => "mcptools")+ expect(data["comments"]).to contain_exactly(a_hash_including("author" => "stranger", "body" => "LGTM"))+ expect(data["diff"]).to include("feature.rb").and include("+puts 'shiny new feature'")+ expect(data).not_to have_key("diff_truncated")+ end++ it "truncates an oversized diff and says so" do+ big_diff = "+x" * Mcp::Tools::GetPullRequest::DIFF_MAX_BYTES+ allow_any_instance_of(Repository).to receive(:diff_between).and_return(big_diff)++ data = payload(call_tool("get_pull_request", { "repo" => "mcptools/demo", "number" => 1 }))+ expect(data["diff_truncated"]).to be(true)+ expect(data["diff"].bytesize).to eq(Mcp::Tools::GetPullRequest::DIFF_MAX_BYTES)+ expect(data["diff_note"]).to match(/truncated/i)+ end++ it "returns a nil diff with a note when a branch no longer exists" do+ pull_request.update!(head_ref: "deleted-branch")++ data = payload(call_tool("get_pull_request", { "repo" => "mcptools/demo", "number" => 1 }))+ expect(data["diff"]).to be_nil+ expect(data["diff_note"]).to match(/branch no longer exists/i)+ end++ it "reports an unknown pull request number in-band" do+ result = call_tool("get_pull_request", { "repo" => "mcptools/demo", "number" => 99 })+ expect(error_text(result)).to match(/No pull request #99/)+ end++ it "rejects a repository the caller cannot see" do+ result = call_tool("get_pull_request", { "repo" => "stranger/secret", "number" => 1 })+ expect(error_text(result)).to match(/not found or not accessible/i)+ end++ it "rejects a call missing the number argument" do+ result = call_tool("get_pull_request", { "repo" => "mcptools/demo" })+ expect(error_text(result)).to match(/Missing required argument: number/)+ end+ end++ # A bare repo whose `feature` branch adds one file on top of `main`, so the+ # three-dot diff a pull request shows is small and predictable.+ def build_repo_with_feature_branch(bare_path)+ FileUtils.mkdir_p(bare_path)+ system("git", "init", "--bare", bare_path, exception: true)+ Dir.mktmpdir do |work|+ system("git", "-C", work, "init", "-b", "main", exception: true)+ system("git", "-C", work, "config", "user.email", "t@example.com", exception: true)+ system("git", "-C", work, "config", "user.name", "Test", exception: true)+ File.write(File.join(work, "README.md"), "# Demo\n")+ system("git", "-C", work, "add", ".", exception: true)+ system("git", "-C", work, "commit", "-m", "seed", exception: true)+ system("git", "-C", work, "checkout", "-b", "feature", exception: true)+ File.write(File.join(work, "feature.rb"), "puts 'shiny new feature'\n")+ system("git", "-C", work, "add", ".", exception: true)+ system("git", "-C", work, "commit", "-m", "add feature", exception: true)+ system("git", "-C", work, "remote", "add", "origin", bare_path, exception: true)+ system("git", "-C", work, "push", "origin", "main", "feature", exception: true)+ end+ end+end
spec/services/issue_service_spec.rb
+41
new file mode 100644index 0000000..edda2fe--- /dev/null+++ b/spec/services/issue_service_spec.rb@@ -0,0 +1,41 @@+# frozen_string_literal: true++require "rails_helper"++RSpec.describe IssueService do+ let(:owner) { User.create!(smbcloud_id: 9001, email: "owner@example.com", username: "owner") }+ let(:reader) { User.create!(smbcloud_id: 9002, email: "reader@example.com", username: "reader") }+ let(:repo) do+ owner.repositories.create!(+ name: "app", kind: "code", default_branch: "main",+ disk_path: GitRepositoryService.repo_path("owner", "app")+ )+ end++ it "opens an issue authored by the given user" do+ issue = described_class.create(repository: repo, author: owner, title: "Bug", body: "Details")++ expect(issue).to be_persisted+ expect(issue.number).to eq(1)+ expect(issue.state).to eq("open")+ expect(issue.author).to eq(owner)+ expect(issue.body).to eq("Details")+ end++ it "lets any reader open an issue, not only the owner" do+ issue = described_class.create(repository: repo, author: reader, title: "Feature request")+ expect(issue.author).to eq(reader)+ end++ it "shares one number space with pull requests" do+ repo.pull_requests.create!(user: owner, number: 3, title: "PR", head_ref: "feature", base_ref: "main")+ issue = described_class.create(repository: repo, author: owner, title: "Bug")+ expect(issue.number).to eq(4)+ end++ it "rejects a blank title via model validation" do+ expect do+ described_class.create(repository: repo, author: owner, title: "")+ end.to raise_error(ActiveRecord::RecordInvalid)+ end+end