mcp-gitlabpr 1.0.0__tar.gz → 1.0.2__tar.gz

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.
@@ -2,6 +2,7 @@ __pycache__/
2
2
  *.pyc
3
3
  .venv/
4
4
  .pytest_cache/
5
+ .superpowers/
5
6
  dist/
6
7
  .DS_Store
7
- uv.lock
8
+ uv.lock
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-gitlabpr
3
+ Version: 1.0.2
4
+ Summary: MCP server para merge requests do GitLab (API v4, CE 10.8+): listar, obter, criar e mesclar, só no projeto do workspace
5
+ Project-URL: Repository, https://github.com/dxaraujo/mcp-gitlabpr
6
+ Author-email: Daniel Xavier Araújo <danielxaraujo@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: mcp[cli]>=2.2
11
+ Requires-Dist: requests>=2.31
12
+ Description-Content-Type: text/markdown
13
+
14
+ # mcp-gitlabpr
15
+
16
+ Servidor MCP para **merge requests** do GitLab (o "PR" do GitLab): listar, obter (com diffs), criar e mesclar. Usa a API REST
17
+ v4 com personal access token e só os parâmetros que já existem na **GitLab CE 10.8**
18
+ ([doc da 10.8.7](https://gitlab.com/gitlab-org/gitlab-foss/-/blob/v10.8.7/doc/api/merge_requests.md)), então também
19
+ funciona em versões mais novas.
20
+
21
+ ## Credenciais
22
+
23
+ | SO | Arquivo |
24
+ |---|---|
25
+ | Linux/Mac | `~/.config/mcp-gitlabpr/gitlabpr.properties` |
26
+ | Windows | `%APPDATA%\mcp-gitlabpr\gitlabpr.properties` |
27
+
28
+ Para usar outro caminho, defina a variável de ambiente `MCP_GITLABPR_CONFIG`.
29
+
30
+ ```ini
31
+ [DEFAULT]
32
+ url = https://gitlab.SEU-SERVIDOR
33
+ token = SEU_PERSONAL_ACCESS_TOKEN
34
+ ```
35
+
36
+ O token sai de *Settings → Access Tokens* com o escopo `api` e vai no header `PRIVATE-TOKEN`.
37
+
38
+ **Certificados:** o MCP confia nas CAs da máquina e, se existir, em `gitlabpr-bundle.pem` na mesma pasta do
39
+ `gitlabpr.properties` (cadeia PEM do servidor). `REQUESTS_CA_BUNDLE` também funciona.
40
+
41
+ ## Instalação
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "gitlabpr": {
47
+ "type": "stdio",
48
+ "command": "uvx",
49
+ "args": ["mcp-gitlabpr@latest"],
50
+ "env": { "UV_SYSTEM_CERTS": "true" }
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ Desenvolvimento:
57
+
58
+ ```bash
59
+ uv sync
60
+ uv run pytest
61
+ uv run mcp dev mcp_gitlabpr/server.py # MCP Inspector
62
+ ```
63
+
64
+ ## Tools
65
+
66
+ As tools só trabalham no **projeto do workspace**: não há como consultar outro projeto nem MRs de todos os projetos.
67
+ O projeto vem do remote do git (`origin`, ou o primeiro remote; aceita `https://`, `ssh://` e `git@host:`) da `pasta`
68
+ informada, o caminho absoluto da raiz do workspace, e `origem` vem da branch atual dela. O agente deve passar `pasta`
69
+ sempre: o Kiro não inicia o MCP de um power na pasta do workspace, e sem `pasta` vale o cwd do processo. Fora de um
70
+ repositório, a pessoa cola a URL do projeto no GitLab (ex.: `https://gitlab.SEU-SERVIDOR/grupo/projeto`; a URL de um MR do
71
+ projeto também serve) e o agente a passa em `url_projeto`; aí `origem` é obrigatória no `gitlab_criar_mr`. O host do
72
+ remote ou da URL precisa ser o do `gitlabpr.properties`; se não for, a tool recusa. `iid` é o número do MR no projeto (o `!123`
73
+ da tela). Autor e responsável são usernames: o MCP os converte em id por `GET /users?username=`, porque a 10.8 só
74
+ filtra por `author_id`/`assignee_id`.
75
+
76
+ | Tool | Para quê |
77
+ |---|---|
78
+ | `gitlab_listar_mrs(pasta?, url_projeto?, estado=opened, busca?, origem?, destino?, autor?, responsavel?, limite≤100, pagina=1)` | MRs do projeto. Devolve `total`, `proxima_pagina` e os itens resumidos |
79
+ | `gitlab_obter_mr(iid, pasta?, url_projeto?, diffs=false)` | Detalhes do MR (descrição, labels, conflitos, merge). Com `diffs`, os arquivos alterados e o diff de cada um, cortado em 4000 caracteres |
80
+ | `gitlab_criar_mr(titulo, destino, pasta?, url_projeto?, origem?, descricao?, responsavel?, labels?, remover_origem=true)` | Cria o MR, já com "apagar a branch de origem após o merge" marcado (passe `remover_origem=false` para manter a branch); a branch de origem precisa já estar no GitLab (`git push`). Se já existe um MR aberto entre as mesmas branches, o GitLab responde 409 |
81
+ | `gitlab_mesclar_mr(iid, pasta?, url_projeto?, sha?, remover_origem?, mensagem?)` | Faz o merge de um MR aberto; recusa antes de chamar o GitLab se ele está em WIP, tem conflito ou não é mesclável. Com `sha`, o GitLab recusa (409) se a branch recebeu commits depois desse commit |
82
+
83
+ `estado` aceita `opened`, `closed`, `merged` ou `all`.
@@ -0,0 +1,70 @@
1
+ # mcp-gitlabpr
2
+
3
+ Servidor MCP para **merge requests** do GitLab (o "PR" do GitLab): listar, obter (com diffs), criar e mesclar. Usa a API REST
4
+ v4 com personal access token e só os parâmetros que já existem na **GitLab CE 10.8**
5
+ ([doc da 10.8.7](https://gitlab.com/gitlab-org/gitlab-foss/-/blob/v10.8.7/doc/api/merge_requests.md)), então também
6
+ funciona em versões mais novas.
7
+
8
+ ## Credenciais
9
+
10
+ | SO | Arquivo |
11
+ |---|---|
12
+ | Linux/Mac | `~/.config/mcp-gitlabpr/gitlabpr.properties` |
13
+ | Windows | `%APPDATA%\mcp-gitlabpr\gitlabpr.properties` |
14
+
15
+ Para usar outro caminho, defina a variável de ambiente `MCP_GITLABPR_CONFIG`.
16
+
17
+ ```ini
18
+ [DEFAULT]
19
+ url = https://gitlab.SEU-SERVIDOR
20
+ token = SEU_PERSONAL_ACCESS_TOKEN
21
+ ```
22
+
23
+ O token sai de *Settings → Access Tokens* com o escopo `api` e vai no header `PRIVATE-TOKEN`.
24
+
25
+ **Certificados:** o MCP confia nas CAs da máquina e, se existir, em `gitlabpr-bundle.pem` na mesma pasta do
26
+ `gitlabpr.properties` (cadeia PEM do servidor). `REQUESTS_CA_BUNDLE` também funciona.
27
+
28
+ ## Instalação
29
+
30
+ ```json
31
+ {
32
+ "mcpServers": {
33
+ "gitlabpr": {
34
+ "type": "stdio",
35
+ "command": "uvx",
36
+ "args": ["mcp-gitlabpr@latest"],
37
+ "env": { "UV_SYSTEM_CERTS": "true" }
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ Desenvolvimento:
44
+
45
+ ```bash
46
+ uv sync
47
+ uv run pytest
48
+ uv run mcp dev mcp_gitlabpr/server.py # MCP Inspector
49
+ ```
50
+
51
+ ## Tools
52
+
53
+ As tools só trabalham no **projeto do workspace**: não há como consultar outro projeto nem MRs de todos os projetos.
54
+ O projeto vem do remote do git (`origin`, ou o primeiro remote; aceita `https://`, `ssh://` e `git@host:`) da `pasta`
55
+ informada, o caminho absoluto da raiz do workspace, e `origem` vem da branch atual dela. O agente deve passar `pasta`
56
+ sempre: o Kiro não inicia o MCP de um power na pasta do workspace, e sem `pasta` vale o cwd do processo. Fora de um
57
+ repositório, a pessoa cola a URL do projeto no GitLab (ex.: `https://gitlab.SEU-SERVIDOR/grupo/projeto`; a URL de um MR do
58
+ projeto também serve) e o agente a passa em `url_projeto`; aí `origem` é obrigatória no `gitlab_criar_mr`. O host do
59
+ remote ou da URL precisa ser o do `gitlabpr.properties`; se não for, a tool recusa. `iid` é o número do MR no projeto (o `!123`
60
+ da tela). Autor e responsável são usernames: o MCP os converte em id por `GET /users?username=`, porque a 10.8 só
61
+ filtra por `author_id`/`assignee_id`.
62
+
63
+ | Tool | Para quê |
64
+ |---|---|
65
+ | `gitlab_listar_mrs(pasta?, url_projeto?, estado=opened, busca?, origem?, destino?, autor?, responsavel?, limite≤100, pagina=1)` | MRs do projeto. Devolve `total`, `proxima_pagina` e os itens resumidos |
66
+ | `gitlab_obter_mr(iid, pasta?, url_projeto?, diffs=false)` | Detalhes do MR (descrição, labels, conflitos, merge). Com `diffs`, os arquivos alterados e o diff de cada um, cortado em 4000 caracteres |
67
+ | `gitlab_criar_mr(titulo, destino, pasta?, url_projeto?, origem?, descricao?, responsavel?, labels?, remover_origem=true)` | Cria o MR, já com "apagar a branch de origem após o merge" marcado (passe `remover_origem=false` para manter a branch); a branch de origem precisa já estar no GitLab (`git push`). Se já existe um MR aberto entre as mesmas branches, o GitLab responde 409 |
68
+ | `gitlab_mesclar_mr(iid, pasta?, url_projeto?, sha?, remover_origem?, mensagem?)` | Faz o merge de um MR aberto; recusa antes de chamar o GitLab se ele está em WIP, tem conflito ou não é mesclável. Com `sha`, o GitLab recusa (409) se a branch recebeu commits depois desse commit |
69
+
70
+ `estado` aceita `opened`, `closed`, `merged` ou `all`.
@@ -1,9 +1,11 @@
1
- """Servidor MCP para merge requests do GitLab: listar, obter (com diffs) e criar.
1
+ """Servidor MCP para merge requests do GitLab: listar, obter (com diffs), criar e mesclar.
2
2
 
3
3
  API REST v4 com personal access token (header PRIVATE-TOKEN, escopo `api`). Só usa parâmetros que já existem na
4
4
  GitLab CE 10.8: autor e responsável vão por id (`author_username` é de versões novas), resolvidos por username.
5
5
 
6
- Sem `projeto`, ele vem do remote do git da pasta em que o MCP roda (origin, ou o primeiro remote).
6
+ Só trabalha no projeto do repositório git do workspace (`pasta`; sem ela, a pasta em que o MCP roda), pelo remote
7
+ origin ou o primeiro remote. Fora de um repositório, a pessoa cola a URL do projeto no GitLab (`url_projeto`). O host
8
+ precisa ser o do gitlabpr.properties.
7
9
  """
8
10
  from __future__ import annotations
9
11
 
@@ -24,11 +26,13 @@ from .config import GitlabConfig, default_path, load_config
24
26
  mcp = MCPServer(
25
27
  "gitlabpr",
26
28
  instructions=(
27
- "Merge requests (MR, o 'PR' do GitLab). `projeto` é o caminho 'grupo/projeto' ou o id numérico; sem ele, vem "
28
- "do remote do repositório git da pasta atual e `origem` vem da branch atual. Fora de um repositório git a tool "
29
- "falha: pergunte ao usuário o projeto (e a branch de origem) e informe-os. `iid` é o número do MR dentro do "
30
- "projeto (o !123 da tela). Autor e responsável são usernames. Liste com "
31
- "gitlab_listar_mrs, detalhe (e veja os diffs) com gitlab_obter_mr, crie com gitlab_criar_mr."
29
+ "Merge requests (MR, o 'PR' do GitLab) só do projeto em que se está trabalhando: ele vem do remote do "
30
+ "repositório git em `pasta` e `origem` vem da branch atual dele. Informe sempre `pasta` com o caminho absoluto "
31
+ "da raiz do workspace. Se a tool disser que não achou o projeto, peça à pessoa a URL do projeto no GitLab "
32
+ "(ex.: https://gitlab/grupo/projeto) e informe-a em `url_projeto`; nunca a deduza nem a invente. `iid` é o "
33
+ "número do MR dentro do projeto (o !123 "
34
+ "da tela). Autor e responsável são usernames. Liste com gitlab_listar_mrs, detalhe (e veja os diffs) com "
35
+ "gitlab_obter_mr, crie com gitlab_criar_mr e faça o merge com gitlab_mesclar_mr."
32
36
  ),
33
37
  )
34
38
 
@@ -113,10 +117,12 @@ def set_session(session: GitlabSession | None) -> None:
113
117
 
114
118
  # ---------------------------------------------------------------- helpers
115
119
 
116
- # ponytail: usa o cwd do processo MCP; se o cliente não o iniciar na pasta do workspace, informe `projeto`
117
- def _git(*args: str) -> str | None:
120
+ def _git(pasta: str | None, *args: str) -> str | None:
121
+ """git na `pasta` do workspace; sem ela, no cwd do processo MCP (o Kiro não inicia o de um power no workspace)."""
122
+ if pasta and not Path(pasta).is_dir():
123
+ raise ValueError(f"`pasta` não existe: {pasta}. Informe o caminho absoluto da raiz do workspace.")
118
124
  try:
119
- r = subprocess.run(["git", *args], capture_output=True, text=True, timeout=10)
125
+ r = subprocess.run(["git", *args], capture_output=True, text=True, timeout=10, cwd=pasta or None)
120
126
  except (OSError, subprocess.TimeoutExpired):
121
127
  return None
122
128
  return (r.stdout.strip() or None) if r.returncode == 0 else None
@@ -124,9 +130,13 @@ def _git(*args: str) -> str | None:
124
130
 
125
131
  def _caminho_remoto(url: str) -> str:
126
132
  """'grupo/projeto' de https://host[/prefixo]/g/p.git, ssh://git@host:22/g/p.git ou git@host:g/p.git."""
133
+ gitlab = urlsplit(get_session().cfg.url)
134
+ host = urlsplit(url).hostname if "://" in url else url.partition(":")[0].rpartition("@")[2]
135
+ if (host or "").lower() != (gitlab.hostname or "").lower():
136
+ raise ValueError(f"O remote do repositório ({url}) não é do GitLab configurado ({get_session().cfg.url}).")
127
137
  if "://" in url:
128
138
  path = urlsplit(url).path
129
- prefixo = urlsplit(get_session().cfg.url).path.rstrip("/") # GitLab sob subcaminho (https://host/gitlab)
139
+ prefixo = gitlab.path.rstrip("/") # GitLab sob subcaminho (https://host/gitlab)
130
140
  if prefixo and path.startswith(prefixo + "/") and url.startswith(("http://", "https://")):
131
141
  path = path[len(prefixo):]
132
142
  else:
@@ -135,22 +145,26 @@ def _caminho_remoto(url: str) -> str:
135
145
  return path[:-4] if path.endswith(".git") else path
136
146
 
137
147
 
138
- def _projeto(projeto: str | int | None) -> str:
139
- """'grupo/projeto' vai URL-encoded no path (grupo%2Fprojeto); id numérico passa como está; sem ele, o remote do git."""
140
- p = str(projeto).strip().strip("/") if projeto is not None else ""
141
- if not p:
142
- remotes = (_git("remote") or "").split()
143
- remote = "origin" if "origin" in remotes else (remotes[0] if remotes else None)
144
- url = remote and _git("remote", "get-url", remote)
145
- if not url:
146
- raise ValueError("Pasta atual não é um repositório git com remote: pergunte ao usuário o projeto "
147
- "('grupo/projeto' ou id numérico) e informe-o em `projeto`.")
148
- p = _caminho_remoto(url)
149
- return quote(p, safe="")
150
-
151
-
152
- def _branch_atual() -> str:
153
- branch = _git("rev-parse", "--abbrev-ref", "HEAD")
148
+ def _projeto(pasta: str | None, url_projeto: str | None = None) -> str:
149
+ """'grupo/projeto' da URL colada pela pessoa ou do remote do git, URL-encoded para o path (grupo%2Fprojeto)."""
150
+ if url_projeto:
151
+ url = url_projeto.strip()
152
+ if not url.startswith(("https://", "http://")):
153
+ raise ValueError(f"`url_projeto` precisa ser a URL do projeto no GitLab (https://...): {url}")
154
+ url = url.split("/-/")[0].split("/merge_requests")[0] # aceita a URL de um MR ou de uma página do projeto
155
+ return quote(_caminho_remoto(url), safe="")
156
+ remotes = (_git(pasta, "remote") or "").split()
157
+ remote = "origin" if "origin" in remotes else (remotes[0] if remotes else None)
158
+ url = remote and _git(pasta, "remote", "get-url", remote)
159
+ if not url:
160
+ raise ValueError(f"Projeto não encontrado: {pasta or Path.cwd()} não é um repositório git com remote. Informe "
161
+ "em `pasta` a raiz do workspace ou peça à pessoa a URL do projeto no GitLab e informe-a em "
162
+ "`url_projeto`.")
163
+ return quote(_caminho_remoto(url), safe="")
164
+
165
+
166
+ def _branch_atual(pasta: str | None) -> str:
167
+ branch = _git(pasta, "rev-parse", "--abbrev-ref", "HEAD")
154
168
  if not branch or branch == "HEAD":
155
169
  raise ValueError("Sem branch atual no git (fora de um repositório ou HEAD destacado): pergunte ao usuário a "
156
170
  "branch de origem e informe-a em `origem`.")
@@ -190,12 +204,12 @@ def _resumo(mr: dict) -> dict:
190
204
  # ---------------------------------------------------------------- tools
191
205
 
192
206
  @tool
193
- def gitlab_listar_mrs(projeto: str | None = None, todos_projetos: bool = False, estado: str = "opened",
207
+ def gitlab_listar_mrs(pasta: str | None = None, url_projeto: str | None = None, estado: str = "opened",
194
208
  busca: str | None = None, origem: str | None = None, destino: str | None = None,
195
209
  autor: str | None = None, responsavel: str | None = None, limite: int = 20,
196
210
  pagina: int = 1) -> dict:
197
- """Lista merge requests de `projeto` ('grupo/projeto' ou id; sem ele, o do repositório git atual) ou, com
198
- todos_projetos=True, de todos os projetos visíveis.
211
+ """Lista merge requests do projeto do repositório git em `pasta` (caminho absoluto da raiz do workspace) ou
212
+ de `url_projeto` (a URL do projeto no GitLab, colada pela pessoa).
199
213
 
200
214
  estado: opened (padrão), closed, merged ou all. busca: texto no título/descrição.
201
215
  origem/destino: branches. autor/responsavel: usernames. limite ≤ 100 por página.
@@ -214,11 +228,7 @@ def gitlab_listar_mrs(projeto: str | None = None, todos_projetos: bool = False,
214
228
  params["author_id"] = _usuario_id(autor)
215
229
  if responsavel:
216
230
  params["assignee_id"] = _usuario_id(responsavel)
217
- if todos_projetos:
218
- path, params["scope"] = "/merge_requests", "all" # padrão do endpoint global é created-by-me
219
- else:
220
- path = f"/projects/{_projeto(projeto)}/merge_requests"
221
- resp = get_session().request("GET", path, params=params)
231
+ resp = get_session().request("GET", f"/projects/{_projeto(pasta, url_projeto)}/merge_requests", params=params)
222
232
  return {
223
233
  "total": int(resp.headers["X-Total"]) if resp.headers.get("X-Total") else None,
224
234
  "pagina": params["page"],
@@ -228,10 +238,10 @@ def gitlab_listar_mrs(projeto: str | None = None, todos_projetos: bool = False,
228
238
 
229
239
 
230
240
  @tool
231
- def gitlab_obter_mr(iid: int, projeto: str | None = None, diffs: bool = False) -> dict:
232
- """Detalhes de um merge request (descrição, labels, merge); `projeto` padrão: o do repositório git atual.
241
+ def gitlab_obter_mr(iid: int, pasta: str | None = None, url_projeto: str | None = None, diffs: bool = False) -> dict:
242
+ """Detalhes de um merge request do projeto do repositório git em `pasta` (descrição, labels, merge).
233
243
  Com diffs=True, inclui os arquivos alterados e o diff de cada um (cortado em 4000 caracteres)."""
234
- base = f"/projects/{_projeto(projeto)}/merge_requests/{int(iid)}"
244
+ base = f"/projects/{_projeto(pasta, url_projeto)}/merge_requests/{int(iid)}"
235
245
  mr = get_session().request("GET", base + ("/changes" if diffs else "")).json()
236
246
  out = _resumo(mr) | {
237
247
  "descricao": mr.get("description"),
@@ -255,15 +265,17 @@ def gitlab_obter_mr(iid: int, projeto: str | None = None, diffs: bool = False) -
255
265
 
256
266
 
257
267
  @tool
258
- def gitlab_criar_mr(titulo: str, destino: str, origem: str | None = None, projeto: str | None = None,
259
- descricao: str | None = None, responsavel: str | None = None, labels: list[str] | None = None,
260
- remover_origem: bool = False) -> dict:
261
- """Cria um merge request de `origem` (padrão: branch atual do git) para `destino` no `projeto` (padrão: o do
262
- repositório git atual). A branch de origem precisa estar no GitLab (git push). responsavel: username.
263
- remover_origem: apaga a branch de origem após o merge. Falha (HTTP 409) se já existe MR aberto entre as branches."""
268
+ def gitlab_criar_mr(titulo: str, destino: str, pasta: str | None = None, url_projeto: str | None = None,
269
+ origem: str | None = None, descricao: str | None = None, responsavel: str | None = None,
270
+ labels: list[str] | None = None, remover_origem: bool = True) -> dict:
271
+ """Cria um merge request de `origem` (padrão: branch atual do git) para `destino` no projeto do repositório
272
+ git em `pasta`. A branch de origem precisa estar no GitLab (git push). responsavel: username.
273
+ remover_origem (padrão: sim): o MR já nasce com 'apagar a branch de origem após o merge' marcado; passe False para manter a branch. Falha (HTTP 409) se já existe MR aberto entre as branches."""
264
274
  if not titulo.strip():
265
275
  raise ValueError("Informe `titulo`.")
266
- data = {"source_branch": origem or _branch_atual(), "target_branch": destino, "title": titulo,
276
+ if url_projeto and not origem:
277
+ raise ValueError("Com `url_projeto`, pergunte ao usuário a branch de origem e informe-a em `origem`.")
278
+ data = {"source_branch": origem or _branch_atual(pasta), "target_branch": destino, "title": titulo,
267
279
  "remove_source_branch": remover_origem}
268
280
  if descricao:
269
281
  data["description"] = descricao
@@ -271,10 +283,37 @@ def gitlab_criar_mr(titulo: str, destino: str, origem: str | None = None, projet
271
283
  data["assignee_id"] = _usuario_id(responsavel)
272
284
  if labels:
273
285
  data["labels"] = ",".join(labels)
274
- mr = get_session().request("POST", f"/projects/{_projeto(projeto)}/merge_requests", json=data).json()
286
+ mr = get_session().request("POST", f"/projects/{_projeto(pasta, url_projeto)}/merge_requests", json=data).json()
275
287
  return _resumo(mr)
276
288
 
277
289
 
290
+ @tool
291
+ def gitlab_mesclar_mr(iid: int, pasta: str | None = None, url_projeto: str | None = None, sha: str | None = None,
292
+ remover_origem: bool | None = None, mensagem: str | None = None) -> dict:
293
+ """Faz o merge de um merge request aberto do projeto do repositório git em `pasta`. Antes confere que ele não
294
+ está em WIP, não tem conflito e que o GitLab o considera mesclável. sha: o commit aprovado; se a branch recebeu
295
+ commits depois, o GitLab recusa (HTTP 409). remover_origem: apaga a branch de origem (padrão: o que o MR já
296
+ define). mensagem: do commit de merge."""
297
+ base = f"/projects/{_projeto(pasta, url_projeto)}/merge_requests/{int(iid)}"
298
+ mr = get_session().request("GET", base).json()
299
+ if mr.get("state") != "opened":
300
+ raise ValueError(f"MR !{iid} não está aberto (estado: {mr.get('state')}).")
301
+ if mr.get("work_in_progress"):
302
+ raise ValueError(f"MR !{iid} está em WIP/Draft: tire o prefixo antes do merge.")
303
+ if mr.get("has_conflicts") or mr.get("merge_status") == "cannot_be_merged":
304
+ raise ValueError(f"MR !{iid} tem conflito com {mr.get('target_branch')}: atualize a branch antes do merge.")
305
+ data = {}
306
+ if sha:
307
+ data["sha"] = sha
308
+ if remover_origem is not None:
309
+ data["should_remove_source_branch"] = remover_origem
310
+ if mensagem:
311
+ data["merge_commit_message"] = mensagem
312
+ mr = get_session().request("PUT", base + "/merge", json=data).json()
313
+ return _resumo(mr) | {"mesclado_por": _login(mr.get("merged_by")), "mesclado_em": mr.get("merged_at"),
314
+ "sha_merge": mr.get("merge_commit_sha")}
315
+
316
+
278
317
  def main() -> None:
279
318
  mcp.run()
280
319
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "mcp-gitlabpr"
3
- version = "1.0.0"
4
- description = "MCP server para merge requests do GitLab (API v4, CE 10.8+): listar, obter e criar"
3
+ version = "1.0.2"
4
+ description = "MCP server para merge requests do GitLab (API v4, CE 10.8+): listar, obter, criar e mesclar, só no projeto do workspace"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
@@ -13,7 +13,7 @@ dependencies = [
13
13
  ]
14
14
 
15
15
  [project.urls]
16
- Repository = "https://github.com/dxaraujo/kiro/tree/main/mcps/gitlabpr"
16
+ Repository = "https://github.com/dxaraujo/mcp-gitlabpr"
17
17
 
18
18
  [project.scripts]
19
19
  mcp-gitlabpr = "mcp_gitlabpr.server:main"
@@ -35,8 +35,17 @@ class FakeAdapter(BaseAdapter):
35
35
  pass
36
36
 
37
37
 
38
+ def git(*args):
39
+ subprocess.run(["git", *args], check=True, capture_output=True)
40
+
41
+
38
42
  @pytest.fixture
39
- def fake():
43
+ def fake(tmp_path_factory, monkeypatch):
44
+ """Sessão falsa, rodando num repositório git cujo origin é o projeto g/p do GitLab de teste."""
45
+ repo = tmp_path_factory.mktemp("repo")
46
+ monkeypatch.chdir(repo)
47
+ git("init", "-q", "-b", "feat")
48
+ git("remote", "add", "origin", "git@gitlab.test:g/p.git")
40
49
  adapter = FakeAdapter()
41
50
  session = server.GitlabSession(GitlabConfig(url="https://gitlab.test", token="tok"))
42
51
  session.session.mount("https://", adapter)
@@ -59,7 +68,7 @@ def test_config(tmp_path):
59
68
  def test_listar_projeto(fake):
60
69
  fake.routes[("GET", f"{API}/users")] = (200, [{"id": 42}])
61
70
  fake.routes[("GET", f"{API}/projects/g%2Fp/merge_requests")] = (200, [MR], {"X-Total": "1"})
62
- out = server.gitlab_listar_mrs("g/p", autor="@joao", destino="dev", limite=500)
71
+ out = server.gitlab_listar_mrs(autor="@joao", destino="dev", limite=500)
63
72
  req = fake.calls[-1]
64
73
  assert req.headers["PRIVATE-TOKEN"] == "tok"
65
74
  assert "author_id=42" in req.url and "target_branch=dev" in req.url and "per_page=100" in req.url
@@ -67,40 +76,59 @@ def test_listar_projeto(fake):
67
76
  assert out["itens"][0] | {} == server._resumo(MR) and out["itens"][0]["autor"] == "joao"
68
77
 
69
78
 
70
- def test_listar_global_usa_scope_all(fake):
71
- fake.routes[("GET", f"{API}/merge_requests")] = (200, [])
72
- server.gitlab_listar_mrs(todos_projetos=True)
73
- assert "scope=all" in fake.calls[-1].url
79
+ def test_listar_estado_invalido(fake):
74
80
  with pytest.raises(ValueError):
75
- server.gitlab_listar_mrs("g/p", estado="aberto")
81
+ server.gitlab_listar_mrs(estado="aberto")
76
82
 
77
83
 
78
84
  def test_obter_com_diffs(fake):
79
85
  change = {"new_path": "a.py", "old_path": "a.py", "diff": "x" * 5000}
80
- fake.routes[("GET", f"{API}/projects/3/merge_requests/7/changes")] = (200, MR | {"changes": [change]})
81
- out = server.gitlab_obter_mr(7, projeto=3, diffs=True)
86
+ fake.routes[("GET", f"{API}/projects/g%2Fp/merge_requests/7/changes")] = (200, MR | {"changes": [change]})
87
+ out = server.gitlab_obter_mr(7, diffs=True)
82
88
  assert out["arquivos"][0]["diff_cortado"] and len(out["arquivos"][0]["diff"]) == server.DIFF_MAX
83
89
 
84
90
 
85
91
  def test_criar(fake):
86
92
  fake.routes[("GET", f"{API}/users")] = (200, [{"id": 9}])
87
93
  fake.routes[("POST", f"{API}/projects/g%2Fp/merge_requests")] = (201, MR)
88
- out = server.gitlab_criar_mr("Ajuste", "dev", "feat", "g/p", responsavel="maria", labels=["a", "b"])
94
+ out = server.gitlab_criar_mr("Ajuste", "dev", origem="feat", responsavel="maria", labels=["a", "b"])
89
95
  assert json.loads(fake.calls[-1].body) == {"source_branch": "feat", "target_branch": "dev", "title": "Ajuste",
90
- "remove_source_branch": False, "assignee_id": 9, "labels": "a,b"}
96
+ "remove_source_branch": True, "assignee_id": 9, "labels": "a,b"}
91
97
  assert out["iid"] == 7
98
+ server.gitlab_criar_mr("Ajuste", "dev", origem="feat", remover_origem=False) # quem quiser manter a branch
99
+ assert json.loads(fake.calls[-1].body)["remove_source_branch"] is False
92
100
 
93
101
 
94
102
  def test_erros(fake):
95
103
  fake.routes[("GET", f"{API}/users")] = (200, [])
96
104
  with pytest.raises(LookupError):
97
- server.gitlab_criar_mr("t", "dev", "feat", "g/p", responsavel="ninguem")
105
+ server.gitlab_criar_mr("t", "dev", origem="feat", responsavel="ninguem")
98
106
  fake.routes[("POST", f"{API}/projects/g%2Fp/merge_requests")] = (409, {"message": ["Another open merge request"]})
99
107
  with pytest.raises(RuntimeError, match="409"):
100
- server.gitlab_criar_mr("t", "dev", "feat", "g/p")
108
+ server.gitlab_criar_mr("t", "dev", origem="feat")
101
109
  fake.routes[("GET", f"{API}/projects/g%2Fp/merge_requests/1")] = (401, {"message": "401 Unauthorized"})
102
110
  with pytest.raises(RuntimeError, match="token"):
103
- server.gitlab_obter_mr(1, "g/p")
111
+ server.gitlab_obter_mr(1)
112
+
113
+
114
+ def test_mesclar(fake):
115
+ base = f"{API}/projects/g%2Fp/merge_requests/7"
116
+ fake.routes[("GET", base)] = (200, MR | {"merge_status": "can_be_merged"})
117
+ fake.routes[("PUT", base + "/merge")] = (200, MR | {"state": "merged", "merged_by": {"username": "ana"},
118
+ "merge_commit_sha": "abc"})
119
+ out = server.gitlab_mesclar_mr(7, sha="f00", remover_origem=True)
120
+ assert json.loads(fake.calls[-1].body) == {"sha": "f00", "should_remove_source_branch": True}
121
+ assert out["estado"] == "merged" and out["mesclado_por"] == "ana" and out["sha_merge"] == "abc"
122
+
123
+
124
+ @pytest.mark.parametrize("extra, erro", [({"state": "merged"}, "aberto"), ({"work_in_progress": True}, "WIP"),
125
+ ({"has_conflicts": True}, "conflito"),
126
+ ({"merge_status": "cannot_be_merged"}, "conflito")])
127
+ def test_mesclar_recusa(fake, extra, erro):
128
+ fake.routes[("GET", f"{API}/projects/g%2Fp/merge_requests/7")] = (200, MR | extra)
129
+ with pytest.raises(ValueError, match=erro):
130
+ server.gitlab_mesclar_mr(7)
131
+ assert not any(c.method == "PUT" for c in fake.calls)
104
132
 
105
133
 
106
134
  @pytest.mark.parametrize("url", ["https://gitlab.test/g/sub/p.git", "git@gitlab.test:g/sub/p.git",
@@ -114,13 +142,16 @@ def test_caminho_remoto_subcaminho(fake):
114
142
  assert server._caminho_remoto("https://host/gitlab/g/p.git") == "g/p"
115
143
 
116
144
 
117
- def git(*args):
118
- subprocess.run(["git", *args], check=True, capture_output=True)
145
+ @pytest.mark.parametrize("url", ["https://github.com/g/p.git", "git@github.com:g/p.git",
146
+ "ssh://git@github.com/g/p.git"])
147
+ def test_remote_de_outro_host(fake, url):
148
+ with pytest.raises(ValueError, match="não é do GitLab configurado"):
149
+ server._caminho_remoto(url)
119
150
 
120
151
 
121
152
  def test_projeto_e_branch_do_git(fake, tmp_path, monkeypatch):
122
153
  monkeypatch.chdir(tmp_path)
123
- with pytest.raises(ValueError, match="pergunte ao usuário o projeto"):
154
+ with pytest.raises(ValueError, match="raiz do workspace"):
124
155
  server.gitlab_listar_mrs()
125
156
  with pytest.raises(ValueError, match="pergunte ao usuário a branch"):
126
157
  server.gitlab_criar_mr("t", "dev")
@@ -130,3 +161,36 @@ def test_projeto_e_branch_do_git(fake, tmp_path, monkeypatch):
130
161
  fake.routes[("POST", f"{API}/projects/g%2Fp/merge_requests")] = (201, MR)
131
162
  server.gitlab_criar_mr("t", "dev")
132
163
  assert json.loads(fake.calls[-1].body)["source_branch"] == "feat"
164
+
165
+
166
+ def test_pasta_do_workspace(fake, tmp_path, monkeypatch):
167
+ """Com `pasta`, o projeto vem do repositório dela, não do cwd (o Kiro não inicia o MCP de um power no workspace)."""
168
+ repo = tmp_path / "ws"
169
+ repo.mkdir()
170
+ monkeypatch.chdir(repo)
171
+ git("init", "-q", "-b", "feat")
172
+ git("remote", "add", "origin", "https://gitlab.test/outro/ws.git")
173
+ monkeypatch.chdir(tmp_path)
174
+ fake.routes[("GET", f"{API}/projects/outro%2Fws/merge_requests")] = (200, [])
175
+ server.gitlab_listar_mrs(pasta=str(repo))
176
+ assert "/projects/outro%2Fws/" in fake.calls[-1].url
177
+ with pytest.raises(ValueError, match="não existe"):
178
+ server.gitlab_listar_mrs(pasta=str(tmp_path / "nada"))
179
+
180
+
181
+ @pytest.mark.parametrize("url", ["https://gitlab.test/g/p", "https://gitlab.test/g/p/", "https://gitlab.test/g/p.git",
182
+ "https://gitlab.test/g/p/merge_requests/7", "https://gitlab.test/g/p/-/tree/dev"])
183
+ def test_url_projeto_colada(fake, tmp_path, monkeypatch, url):
184
+ """Fora de um repositório, a pessoa cola a URL do projeto; vale a de um MR ou de outra página dele."""
185
+ monkeypatch.chdir(tmp_path)
186
+ fake.routes[("GET", f"{API}/projects/g%2Fp/merge_requests")] = (200, [])
187
+ server.gitlab_listar_mrs(url_projeto=url)
188
+ assert "/projects/g%2Fp/merge_requests" in fake.calls[-1].url
189
+
190
+
191
+ def test_url_projeto_recusada(fake):
192
+ for url in ("https://github.com/g/p", "g/p", "123"):
193
+ with pytest.raises(ValueError):
194
+ server.gitlab_listar_mrs(url_projeto=url)
195
+ with pytest.raises(ValueError, match="branch de origem"):
196
+ server.gitlab_criar_mr("t", "dev", url_projeto="https://gitlab.test/g/p")
@@ -1,79 +0,0 @@
1
- Metadata-Version: 2.5
2
- Name: mcp-gitlabpr
3
- Version: 1.0.0
4
- Summary: MCP server para merge requests do GitLab (API v4, CE 10.8+): listar, obter e criar
5
- Project-URL: Repository, https://github.com/dxaraujo/kiro/tree/main/mcps/gitlabpr
6
- Author-email: Daniel Xavier Araújo <danielxaraujo@gmail.com>
7
- License-Expression: MIT
8
- License-File: LICENSE
9
- Requires-Python: >=3.10
10
- Requires-Dist: mcp[cli]>=2.2
11
- Requires-Dist: requests>=2.31
12
- Description-Content-Type: text/markdown
13
-
14
- # mcp-gitlabpr
15
-
16
- Servidor MCP para **merge requests** do GitLab (o "PR" do GitLab): listar, obter (com diffs) e criar. Usa a API REST
17
- v4 com personal access token e só os parâmetros que já existem na **GitLab CE 10.8**
18
- ([doc da 10.8.7](https://gitlab.com/gitlab-org/gitlab-foss/-/blob/v10.8.7/doc/api/merge_requests.md)), então também
19
- funciona em versões mais novas.
20
-
21
- ## Credenciais
22
-
23
- | SO | Arquivo |
24
- |---|---|
25
- | Linux/Mac | `~/.config/mcp-gitlabpr/gitlabpr.properties` |
26
- | Windows | `%APPDATA%\mcp-gitlabpr\gitlabpr.properties` |
27
-
28
- Para usar outro caminho, defina a variável de ambiente `MCP_GITLABPR_CONFIG`.
29
-
30
- ```ini
31
- [DEFAULT]
32
- url = https://gitlab.SEU-SERVIDOR
33
- token = SEU_PERSONAL_ACCESS_TOKEN
34
- ```
35
-
36
- O token sai de *Settings → Access Tokens* com o escopo `api` e vai no header `PRIVATE-TOKEN`.
37
-
38
- **Certificados:** o MCP confia nas CAs da máquina e, se existir, em `gitlabpr-bundle.pem` na mesma pasta do
39
- `gitlabpr.properties` (cadeia PEM do servidor). `REQUESTS_CA_BUNDLE` também funciona.
40
-
41
- ## Instalação
42
-
43
- ```json
44
- {
45
- "mcpServers": {
46
- "gitlabpr": {
47
- "type": "stdio",
48
- "command": "uvx",
49
- "args": ["mcp-gitlabpr@latest"],
50
- "env": { "UV_SYSTEM_CERTS": "true" }
51
- }
52
- }
53
- }
54
- ```
55
-
56
- Desenvolvimento:
57
-
58
- ```bash
59
- uv sync
60
- uv run pytest
61
- uv run mcp dev mcp_gitlabpr/server.py # MCP Inspector
62
- ```
63
-
64
- ## Tools
65
-
66
- `projeto` é o caminho `grupo/projeto` (o MCP faz o URL-encode) ou o id numérico. **Sem `projeto`**, ele vem do remote
67
- do git da pasta em que o MCP roda (`origin`, ou o primeiro remote; aceita `https://`, `ssh://` e `git@host:`), e
68
- `origem` vem da branch atual. Fora de um repositório git a tool falha pedindo que o agente pergunte ao usuário. O
69
- cliente MCP precisa iniciar o servidor na pasta do workspace; se não iniciar, informe `projeto`. `iid` é o número do MR no projeto
70
- (o `!123` da tela). Autor e responsável são usernames: o MCP os converte em id por `GET /users?username=`, porque a 10.8
71
- só filtra por `author_id`/`assignee_id`.
72
-
73
- | Tool | Para quê |
74
- |---|---|
75
- | `gitlab_listar_mrs(projeto?, todos_projetos=false, estado=opened, busca?, origem?, destino?, autor?, responsavel?, limite≤100, pagina=1)` | MRs do projeto ou, com `todos_projetos`, de todos os visíveis (`scope=all`). Devolve `total`, `proxima_pagina` e os itens resumidos |
76
- | `gitlab_obter_mr(iid, projeto?, diffs=false)` | Detalhes do MR (descrição, labels, conflitos, merge). Com `diffs`, os arquivos alterados e o diff de cada um, cortado em 4000 caracteres |
77
- | `gitlab_criar_mr(titulo, destino, origem?, projeto?, descricao?, responsavel?, labels?, remover_origem=false)` | Cria o MR; a branch de origem precisa já estar no GitLab (`git push`). Se já existe um MR aberto entre as mesmas branches, o GitLab responde 409 |
78
-
79
- `estado` aceita `opened`, `closed`, `merged` ou `all`.
@@ -1,66 +0,0 @@
1
- # mcp-gitlabpr
2
-
3
- Servidor MCP para **merge requests** do GitLab (o "PR" do GitLab): listar, obter (com diffs) e criar. Usa a API REST
4
- v4 com personal access token e só os parâmetros que já existem na **GitLab CE 10.8**
5
- ([doc da 10.8.7](https://gitlab.com/gitlab-org/gitlab-foss/-/blob/v10.8.7/doc/api/merge_requests.md)), então também
6
- funciona em versões mais novas.
7
-
8
- ## Credenciais
9
-
10
- | SO | Arquivo |
11
- |---|---|
12
- | Linux/Mac | `~/.config/mcp-gitlabpr/gitlabpr.properties` |
13
- | Windows | `%APPDATA%\mcp-gitlabpr\gitlabpr.properties` |
14
-
15
- Para usar outro caminho, defina a variável de ambiente `MCP_GITLABPR_CONFIG`.
16
-
17
- ```ini
18
- [DEFAULT]
19
- url = https://gitlab.SEU-SERVIDOR
20
- token = SEU_PERSONAL_ACCESS_TOKEN
21
- ```
22
-
23
- O token sai de *Settings → Access Tokens* com o escopo `api` e vai no header `PRIVATE-TOKEN`.
24
-
25
- **Certificados:** o MCP confia nas CAs da máquina e, se existir, em `gitlabpr-bundle.pem` na mesma pasta do
26
- `gitlabpr.properties` (cadeia PEM do servidor). `REQUESTS_CA_BUNDLE` também funciona.
27
-
28
- ## Instalação
29
-
30
- ```json
31
- {
32
- "mcpServers": {
33
- "gitlabpr": {
34
- "type": "stdio",
35
- "command": "uvx",
36
- "args": ["mcp-gitlabpr@latest"],
37
- "env": { "UV_SYSTEM_CERTS": "true" }
38
- }
39
- }
40
- }
41
- ```
42
-
43
- Desenvolvimento:
44
-
45
- ```bash
46
- uv sync
47
- uv run pytest
48
- uv run mcp dev mcp_gitlabpr/server.py # MCP Inspector
49
- ```
50
-
51
- ## Tools
52
-
53
- `projeto` é o caminho `grupo/projeto` (o MCP faz o URL-encode) ou o id numérico. **Sem `projeto`**, ele vem do remote
54
- do git da pasta em que o MCP roda (`origin`, ou o primeiro remote; aceita `https://`, `ssh://` e `git@host:`), e
55
- `origem` vem da branch atual. Fora de um repositório git a tool falha pedindo que o agente pergunte ao usuário. O
56
- cliente MCP precisa iniciar o servidor na pasta do workspace; se não iniciar, informe `projeto`. `iid` é o número do MR no projeto
57
- (o `!123` da tela). Autor e responsável são usernames: o MCP os converte em id por `GET /users?username=`, porque a 10.8
58
- só filtra por `author_id`/`assignee_id`.
59
-
60
- | Tool | Para quê |
61
- |---|---|
62
- | `gitlab_listar_mrs(projeto?, todos_projetos=false, estado=opened, busca?, origem?, destino?, autor?, responsavel?, limite≤100, pagina=1)` | MRs do projeto ou, com `todos_projetos`, de todos os visíveis (`scope=all`). Devolve `total`, `proxima_pagina` e os itens resumidos |
63
- | `gitlab_obter_mr(iid, projeto?, diffs=false)` | Detalhes do MR (descrição, labels, conflitos, merge). Com `diffs`, os arquivos alterados e o diff de cada um, cortado em 4000 caracteres |
64
- | `gitlab_criar_mr(titulo, destino, origem?, projeto?, descricao?, responsavel?, labels?, remover_origem=false)` | Cria o MR; a branch de origem precisa já estar no GitLab (`git push`). Se já existe um MR aberto entre as mesmas branches, o GitLab responde 409 |
65
-
66
- `estado` aceita `opened`, `closed`, `merged` ou `all`.
File without changes