patlab-mcp 0.1.1__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.
- patlab_mcp-0.1.1/PKG-INFO +13 -0
- patlab_mcp-0.1.1/patlab_mcp/__init__.py +0 -0
- patlab_mcp-0.1.1/patlab_mcp/client.py +55 -0
- patlab_mcp-0.1.1/patlab_mcp/server.py +34 -0
- patlab_mcp-0.1.1/patlab_mcp/tools/__init__.py +0 -0
- patlab_mcp-0.1.1/patlab_mcp/tools/helpers.py +61 -0
- patlab_mcp-0.1.1/patlab_mcp/tools/read.py +157 -0
- patlab_mcp-0.1.1/patlab_mcp/tools/write.py +184 -0
- patlab_mcp-0.1.1/patlab_mcp.egg-info/PKG-INFO +13 -0
- patlab_mcp-0.1.1/patlab_mcp.egg-info/SOURCES.txt +16 -0
- patlab_mcp-0.1.1/patlab_mcp.egg-info/dependency_links.txt +1 -0
- patlab_mcp-0.1.1/patlab_mcp.egg-info/entry_points.txt +2 -0
- patlab_mcp-0.1.1/patlab_mcp.egg-info/requires.txt +10 -0
- patlab_mcp-0.1.1/patlab_mcp.egg-info/top_level.txt +1 -0
- patlab_mcp-0.1.1/pyproject.toml +46 -0
- patlab_mcp-0.1.1/setup.cfg +4 -0
- patlab_mcp-0.1.1/tests/test_read.py +179 -0
- patlab_mcp-0.1.1/tests/test_write.py +316 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: patlab-mcp
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Requires-Python: >=3.11
|
|
5
|
+
Requires-Dist: mcp>=1.9
|
|
6
|
+
Requires-Dist: httpx>=0.28
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
9
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
10
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
11
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
12
|
+
Requires-Dist: ruff>=0.9; extra == "dev"
|
|
13
|
+
Requires-Dist: ty>=0.0.1a0; extra == "dev"
|
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
_DEFAULT_API_URL = "https://gitlab.com/api/v4"
|
|
8
|
+
|
|
9
|
+
_cache: dict[tuple[str, str], Any] = {}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_env() -> None:
|
|
13
|
+
if not os.environ.get("GITLAB_TOKEN"):
|
|
14
|
+
raise RuntimeError("Missing required environment variable: GITLAB_TOKEN")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_gitlab_args(
|
|
18
|
+
project_id: str | None,
|
|
19
|
+
mr_iid: int | None,
|
|
20
|
+
) -> tuple[str, str]:
|
|
21
|
+
"""
|
|
22
|
+
Resolve project_id and mr_iid from explicit args or env vars. Returns (project_id, mr_iid) as strings.
|
|
23
|
+
"""
|
|
24
|
+
resolved_project = project_id or os.environ.get("CI_PROJECT_ID")
|
|
25
|
+
resolved_iid = str(mr_iid) if mr_iid is not None else os.environ.get("CI_MERGE_REQUEST_IID")
|
|
26
|
+
if not resolved_project:
|
|
27
|
+
raise ValueError("Missing project_id: pass explicitly or set CI_PROJECT_ID")
|
|
28
|
+
if not resolved_iid:
|
|
29
|
+
raise ValueError("Missing mr_iid: pass explicitly or set CI_MERGE_REQUEST_IID")
|
|
30
|
+
return resolved_project, resolved_iid
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_client() -> httpx.AsyncClient:
|
|
34
|
+
token = os.environ["GITLAB_TOKEN"]
|
|
35
|
+
base_url = os.environ.get("CI_API_V4_URL", _DEFAULT_API_URL).rstrip("/")
|
|
36
|
+
return httpx.AsyncClient(
|
|
37
|
+
base_url=base_url,
|
|
38
|
+
headers={"PRIVATE-TOKEN": token},
|
|
39
|
+
timeout=30.0,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def mr_base_url(project_id: str, mr_iid: str) -> str:
|
|
44
|
+
return f"/projects/{quote(project_id, safe='')}/merge_requests/{mr_iid}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def get_cached_mr(project_id: str, mr_iid: str, force_origin_read: bool = False) -> dict[str, Any]:
|
|
48
|
+
"""Fetch and cache the MR payload. Pass force=True to overwrite the cache (e.g. after an MR update)."""
|
|
49
|
+
key = (project_id, mr_iid)
|
|
50
|
+
if force_origin_read or key not in _cache:
|
|
51
|
+
async with make_client() as client:
|
|
52
|
+
r = await client.get(mr_base_url(project_id, mr_iid))
|
|
53
|
+
r.raise_for_status()
|
|
54
|
+
_cache[key] = r.json()
|
|
55
|
+
return _cache[key]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
|
|
7
|
+
from patlab_mcp.client import validate_env
|
|
8
|
+
from patlab_mcp.tools import read, write
|
|
9
|
+
|
|
10
|
+
_DURABLE_SUFFIX = (
|
|
11
|
+
"\n\nDurable session: CI env vars not set. Always pass project_id and mr_iid explicitly - "
|
|
12
|
+
"infer from the GitLab URL or user-provided context."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
mcp = FastMCP("patlab")
|
|
16
|
+
_durable = os.environ.get("PATLAB_DURABLE", "").lower() not in ("", "0", "false", "no")
|
|
17
|
+
|
|
18
|
+
for tool in [*read.TOOLS, *write.TOOLS]:
|
|
19
|
+
desc = (tool.__doc__ or "") + _DURABLE_SUFFIX if _durable else tool.__doc__
|
|
20
|
+
mcp.tool(description=desc)(tool)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> None:
|
|
24
|
+
logging.basicConfig(
|
|
25
|
+
level=logging.INFO,
|
|
26
|
+
stream=sys.stderr,
|
|
27
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
28
|
+
)
|
|
29
|
+
validate_env() # only checks GITLAB_TOKEN
|
|
30
|
+
mcp.run()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
# Written on unrecoverable auth failures so the caller can exit 1 after the MCP session finishes.
|
|
6
|
+
_SENTINEL = Path("/tmp/patlab-fatal") # nosemgrep
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def fatal(message: str) -> None:
|
|
10
|
+
_SENTINEL.write_text(message)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def line_code(old_path: str, new_path: str, old_line: int, new_line: int) -> str:
|
|
14
|
+
# Yes, we're using SHA1. Yes, we have to to match Git's behavior.
|
|
15
|
+
sha = hashlib.sha1(f"{old_path}{new_path}".encode()).hexdigest()
|
|
16
|
+
return f"{sha}_{old_line}_{new_line}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def line_ref_added(old_path: str, new_path: str, new_line: int) -> dict[str, Any]:
|
|
20
|
+
"""Build a line_range start/end object for an added line (old_line=0, type='new')."""
|
|
21
|
+
return {
|
|
22
|
+
"line_code": line_code(old_path, new_path, 0, new_line),
|
|
23
|
+
"type": "new",
|
|
24
|
+
"old_line": 0,
|
|
25
|
+
"new_line": new_line,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def suggestion_fence(suggested_code: str, new_line: int, new_line_end: int | None) -> str:
|
|
30
|
+
if new_line_end is not None and new_line_end < new_line:
|
|
31
|
+
raise ValueError(f"new_line_end ({new_line_end}) must be >= new_line ({new_line})")
|
|
32
|
+
offset = 0 if new_line_end is None else new_line_end - new_line
|
|
33
|
+
if not suggested_code.endswith("\n"):
|
|
34
|
+
suggested_code += "\n"
|
|
35
|
+
return f"```suggestion:-0+{offset}\n{suggested_code}```"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def truncate_body(body: str, head: int = 60, tail: int = 20) -> str:
|
|
39
|
+
if len(body) <= head + tail:
|
|
40
|
+
return repr(body)
|
|
41
|
+
return f"{body[:head]!r}...{body[-tail:]!r}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_position(
|
|
45
|
+
diff_refs: dict, new_path: str, old_path: str, new_line: int, new_line_end: int | None
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
position: dict[str, Any] = {
|
|
48
|
+
"position_type": "text",
|
|
49
|
+
"base_sha": diff_refs["base_sha"],
|
|
50
|
+
"head_sha": diff_refs["head_sha"],
|
|
51
|
+
"start_sha": diff_refs["start_sha"],
|
|
52
|
+
"new_path": new_path,
|
|
53
|
+
"old_path": old_path,
|
|
54
|
+
"new_line": new_line,
|
|
55
|
+
}
|
|
56
|
+
if new_line_end is not None:
|
|
57
|
+
position["line_range"] = {
|
|
58
|
+
"start": line_ref_added(old_path, new_path, new_line),
|
|
59
|
+
"end": line_ref_added(old_path, new_path, new_line_end),
|
|
60
|
+
}
|
|
61
|
+
return position
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from patlab_mcp.client import get_cached_mr, make_client, mr_base_url, validate_gitlab_args
|
|
5
|
+
|
|
6
|
+
log = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def get_mr_info(
|
|
10
|
+
project_id: str | None = None,
|
|
11
|
+
mr_iid: int | None = None,
|
|
12
|
+
) -> dict[str, Any]:
|
|
13
|
+
"""
|
|
14
|
+
Fetch metadata for the current MR: title, description, author, source/target
|
|
15
|
+
branches, state, labels, milestone, and the diff_refs block (base_sha, head_sha,
|
|
16
|
+
start_sha) required for posting inline comments.
|
|
17
|
+
|
|
18
|
+
Always fetches fresh from the API (bypasses cache) - MR metadata may change between
|
|
19
|
+
calls in a durable session (e.g. new commits, label changes).
|
|
20
|
+
|
|
21
|
+
Arguments:
|
|
22
|
+
project_id - numeric ID or namespace/repo slug (e.g. "mygroup/myrepo").
|
|
23
|
+
Defaults to CI_PROJECT_ID env var.
|
|
24
|
+
mr_iid - MR internal ID. Defaults to CI_MERGE_REQUEST_IID env var.
|
|
25
|
+
"""
|
|
26
|
+
pid, iid = validate_gitlab_args(project_id, mr_iid)
|
|
27
|
+
log.info(f"get_mr_info project={pid} mr={iid}")
|
|
28
|
+
mr = await get_cached_mr(pid, iid, force_origin_read=True)
|
|
29
|
+
log.info(f"get_mr_info -> iid={mr['iid']} title={mr['title']!r} state={mr['state']}")
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
"iid": mr["iid"],
|
|
33
|
+
"title": mr["title"],
|
|
34
|
+
"description": mr.get("description") or "",
|
|
35
|
+
"state": mr["state"],
|
|
36
|
+
"author": mr["author"]["username"],
|
|
37
|
+
"source_branch": mr["source_branch"],
|
|
38
|
+
"target_branch": mr["target_branch"],
|
|
39
|
+
"labels": mr.get("labels", []),
|
|
40
|
+
"milestone": mr["milestone"]["title"] if mr.get("milestone") else None,
|
|
41
|
+
"diff_refs": mr["diff_refs"],
|
|
42
|
+
"web_url": mr["web_url"],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def get_mr_diff(
|
|
47
|
+
project_id: str | None = None,
|
|
48
|
+
mr_iid: int | None = None,
|
|
49
|
+
) -> list[dict[str, Any]]:
|
|
50
|
+
"""
|
|
51
|
+
Fetch the file diffs for the current MR as raw unified diffs. Use this only
|
|
52
|
+
when `git diff` is unavailable (e.g. GIT_STRATEGY: none).
|
|
53
|
+
|
|
54
|
+
Arguments:
|
|
55
|
+
project_id - numeric ID or namespace/repo slug. Defaults to CI_PROJECT_ID env var.
|
|
56
|
+
mr_iid - MR internal ID. Defaults to CI_MERGE_REQUEST_IID env var.
|
|
57
|
+
|
|
58
|
+
Returns a list of file objects, each with:
|
|
59
|
+
- old_path / new_path
|
|
60
|
+
- new_file / deleted_file / renamed_file: bool flags
|
|
61
|
+
- diff: the raw unified diff string
|
|
62
|
+
"""
|
|
63
|
+
pid, iid = validate_gitlab_args(project_id, mr_iid)
|
|
64
|
+
log.info(f"get_mr_diff project={pid} mr={iid}")
|
|
65
|
+
base = mr_base_url(pid, iid)
|
|
66
|
+
async with make_client() as client:
|
|
67
|
+
raw_diffs: list[dict] = []
|
|
68
|
+
page = 1
|
|
69
|
+
while True:
|
|
70
|
+
r = await client.get(
|
|
71
|
+
base + "/diffs",
|
|
72
|
+
params={"per_page": 100, "page": page},
|
|
73
|
+
)
|
|
74
|
+
r.raise_for_status()
|
|
75
|
+
batch = r.json()
|
|
76
|
+
if not batch:
|
|
77
|
+
break
|
|
78
|
+
raw_diffs.extend(batch)
|
|
79
|
+
page += 1
|
|
80
|
+
|
|
81
|
+
log.info(f"get_mr_diff -> {len(raw_diffs)} files")
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
"old_path": f["old_path"],
|
|
85
|
+
"new_path": f["new_path"],
|
|
86
|
+
"new_file": f.get("new_file", False),
|
|
87
|
+
"deleted_file": f.get("deleted_file", False),
|
|
88
|
+
"renamed_file": f.get("renamed_file", False),
|
|
89
|
+
"diff": f.get("diff", ""),
|
|
90
|
+
}
|
|
91
|
+
for f in raw_diffs
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def list_discussions(
|
|
96
|
+
project_id: str | None = None,
|
|
97
|
+
mr_iid: int | None = None,
|
|
98
|
+
) -> list[dict[str, Any]]:
|
|
99
|
+
"""
|
|
100
|
+
List all discussions (comment threads) on the current MR. Read these before
|
|
101
|
+
posting anything to avoid duplication and to understand what other reviewers
|
|
102
|
+
have raised.
|
|
103
|
+
|
|
104
|
+
Arguments:
|
|
105
|
+
project_id - numeric ID or namespace/repo slug. Defaults to CI_PROJECT_ID env var.
|
|
106
|
+
mr_iid - MR internal ID. Defaults to CI_MERGE_REQUEST_IID env var.
|
|
107
|
+
|
|
108
|
+
Returns a list of discussion objects, each with:
|
|
109
|
+
- id: discussion ID (pass to reply_to_discussion)
|
|
110
|
+
- resolved: bool
|
|
111
|
+
- notes: list of individual comments, each with:
|
|
112
|
+
- id: note ID (pass to react_to_comment)
|
|
113
|
+
- author, body, created_at
|
|
114
|
+
- position: inline position if applicable (may be null)
|
|
115
|
+
- resolvable / resolved
|
|
116
|
+
"""
|
|
117
|
+
pid, iid = validate_gitlab_args(project_id, mr_iid)
|
|
118
|
+
log.info(f"list_discussions project={pid} mr={iid}")
|
|
119
|
+
base = mr_base_url(pid, iid)
|
|
120
|
+
async with make_client() as client:
|
|
121
|
+
discussions: list[dict] = []
|
|
122
|
+
page = 1
|
|
123
|
+
while True:
|
|
124
|
+
r = await client.get(
|
|
125
|
+
base + "/discussions",
|
|
126
|
+
params={"per_page": 100, "page": page},
|
|
127
|
+
)
|
|
128
|
+
r.raise_for_status()
|
|
129
|
+
batch = r.json()
|
|
130
|
+
if not batch:
|
|
131
|
+
break
|
|
132
|
+
discussions.extend(batch)
|
|
133
|
+
page += 1
|
|
134
|
+
|
|
135
|
+
log.info(f"list_discussions -> {len(discussions)} discussions")
|
|
136
|
+
return [
|
|
137
|
+
{
|
|
138
|
+
"id": d["id"],
|
|
139
|
+
"resolved": d.get("resolved", False),
|
|
140
|
+
"notes": [
|
|
141
|
+
{
|
|
142
|
+
"id": n["id"],
|
|
143
|
+
"author": n["author"]["username"],
|
|
144
|
+
"body": n["body"],
|
|
145
|
+
"created_at": n["created_at"],
|
|
146
|
+
"resolvable": n.get("resolvable", False),
|
|
147
|
+
"resolved": n.get("resolved", False),
|
|
148
|
+
"position": n.get("position"),
|
|
149
|
+
}
|
|
150
|
+
for n in d.get("notes", [])
|
|
151
|
+
],
|
|
152
|
+
}
|
|
153
|
+
for d in discussions
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
TOOLS = [get_mr_info, get_mr_diff, list_discussions]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any, Literal
|
|
3
|
+
|
|
4
|
+
from patlab_mcp.client import get_cached_mr, make_client, mr_base_url, validate_gitlab_args
|
|
5
|
+
from patlab_mcp.tools.helpers import build_position, fatal, suggestion_fence, truncate_body
|
|
6
|
+
|
|
7
|
+
log = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
Emoji = Literal[
|
|
10
|
+
"thumbsup", # 👍 valid concern worth acknowledging
|
|
11
|
+
"thumbsdown", # 👎
|
|
12
|
+
"joy", # 😂 genuinely funny comment
|
|
13
|
+
"skull", # 💀 same
|
|
14
|
+
"tada", # 🎉 excellent suggestion
|
|
15
|
+
"raised_hands", # 🙌 same
|
|
16
|
+
"fire", # 🔥 same
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def post_inline_comment(
|
|
21
|
+
new_path: str,
|
|
22
|
+
new_line: int,
|
|
23
|
+
body: str,
|
|
24
|
+
old_path: str | None = None,
|
|
25
|
+
new_line_end: int | None = None,
|
|
26
|
+
suggestion: str | None = None,
|
|
27
|
+
project_id: str | None = None,
|
|
28
|
+
mr_iid: int | None = None,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
"""
|
|
31
|
+
Post an inline comment on a specific line (or line range) of a changed file in the MR diff.
|
|
32
|
+
|
|
33
|
+
Arguments:
|
|
34
|
+
new_path - file path in the new version (as shown in the diff header)
|
|
35
|
+
new_line - line number in the new file version to anchor the comment on.
|
|
36
|
+
For a range, this is the first line. Must be an added line (shown
|
|
37
|
+
as green in the diff). Context and removed lines are not supported.
|
|
38
|
+
body - markdown comment body. Must not contain suggestion syntax - use the
|
|
39
|
+
`suggestion` argument instead. May be empty string if suggestion is provided.
|
|
40
|
+
old_path - only needed for renamed files; defaults to new_path.
|
|
41
|
+
new_line_end - last line of the range for a multi-line comment. When provided,
|
|
42
|
+
the comment spans new_line..new_line_end (inclusive). Required when
|
|
43
|
+
`suggestion` replaces more than one line. Omit for single-line comments.
|
|
44
|
+
suggestion - proposed replacement code for the selected line(s). When provided, the
|
|
45
|
+
tool wraps it in a GitLab suggestion fence with the correct offset computed
|
|
46
|
+
from new_line and new_line_end - do NOT write suggestion syntax in `body`.
|
|
47
|
+
project_id - numeric ID or namespace/repo slug. Defaults to CI_PROJECT_ID env var.
|
|
48
|
+
mr_iid - MR internal ID. Defaults to CI_MERGE_REQUEST_IID env var.
|
|
49
|
+
|
|
50
|
+
Returns the created discussion_id and note_id.
|
|
51
|
+
"""
|
|
52
|
+
pid, iid = validate_gitlab_args(project_id, mr_iid)
|
|
53
|
+
log.info(
|
|
54
|
+
f"post_inline_comment project={pid} mr={iid} path={new_path} line={new_line}"
|
|
55
|
+
+ (f"-{new_line_end}" if new_line_end else "")
|
|
56
|
+
+ f" body={truncate_body(body)}"
|
|
57
|
+
)
|
|
58
|
+
mr = await get_cached_mr(pid, iid)
|
|
59
|
+
resolved_old_path = old_path or new_path
|
|
60
|
+
|
|
61
|
+
if suggestion is not None:
|
|
62
|
+
fence = suggestion_fence(suggestion, new_line, new_line_end)
|
|
63
|
+
full_body = f"{fence}\n\n{body}" if body else fence
|
|
64
|
+
else:
|
|
65
|
+
full_body = body
|
|
66
|
+
|
|
67
|
+
payload = {
|
|
68
|
+
"body": full_body,
|
|
69
|
+
"position": build_position(mr["diff_refs"], new_path, resolved_old_path, new_line, new_line_end),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async with make_client() as client:
|
|
73
|
+
r = await client.post(mr_base_url(pid, iid) + "/discussions", json=payload)
|
|
74
|
+
if r.status_code in (401, 403):
|
|
75
|
+
fatal(f"post_inline_comment: HTTP {r.status_code} - token lacks permission to post comments")
|
|
76
|
+
r.raise_for_status()
|
|
77
|
+
discussion = r.json()
|
|
78
|
+
|
|
79
|
+
result = {"discussion_id": discussion["id"], "note_id": discussion["notes"][0]["id"]}
|
|
80
|
+
log.info(f"post_inline_comment -> {result}")
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def post_summary_comment(
|
|
85
|
+
body: str,
|
|
86
|
+
project_id: str | None = None,
|
|
87
|
+
mr_iid: int | None = None,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
"""
|
|
90
|
+
Post a single MR-level summary comment. Call this once at the end of the review
|
|
91
|
+
to summarise the changeset as a whole - alignment with the MR description, any
|
|
92
|
+
cross-cutting observations, and overall assessment.
|
|
93
|
+
|
|
94
|
+
Arguments:
|
|
95
|
+
project_id - numeric ID or namespace/repo slug. Defaults to CI_PROJECT_ID env var.
|
|
96
|
+
mr_iid - MR internal ID. Defaults to CI_MERGE_REQUEST_IID env var.
|
|
97
|
+
|
|
98
|
+
Returns the created note_id.
|
|
99
|
+
"""
|
|
100
|
+
pid, iid = validate_gitlab_args(project_id, mr_iid)
|
|
101
|
+
log.info(f"post_summary_comment project={pid} mr={iid} body={truncate_body(body)}")
|
|
102
|
+
async with make_client() as client:
|
|
103
|
+
r = await client.post(mr_base_url(pid, iid) + "/notes", json={"body": body})
|
|
104
|
+
if r.status_code in (401, 403):
|
|
105
|
+
fatal(f"post_summary_comment: HTTP {r.status_code} - token lacks permission to post comments")
|
|
106
|
+
r.raise_for_status()
|
|
107
|
+
|
|
108
|
+
result = {"note_id": r.json()["id"]}
|
|
109
|
+
log.info(f"post_summary_comment -> {result}")
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def reply_to_discussion(
|
|
114
|
+
discussion_id: str,
|
|
115
|
+
body: str,
|
|
116
|
+
project_id: str | None = None,
|
|
117
|
+
mr_iid: int | None = None,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""
|
|
120
|
+
Reply to an existing discussion thread. Use this to build on another reviewer's
|
|
121
|
+
comment - e.g. to provide a concrete implementation of a suggested change.
|
|
122
|
+
Do not use it to contradict other reviewers without clear technical grounds.
|
|
123
|
+
Avoid replying to threads that are already resolved or headed toward resolution.
|
|
124
|
+
|
|
125
|
+
Arguments:
|
|
126
|
+
discussion_id - the discussion ID returned by list_discussions.
|
|
127
|
+
project_id - numeric ID or namespace/repo slug. Defaults to CI_PROJECT_ID env var.
|
|
128
|
+
mr_iid - MR internal ID. Defaults to CI_MERGE_REQUEST_IID env var.
|
|
129
|
+
|
|
130
|
+
Returns the created note_id.
|
|
131
|
+
"""
|
|
132
|
+
pid, iid = validate_gitlab_args(project_id, mr_iid)
|
|
133
|
+
log.info(
|
|
134
|
+
f"reply_to_discussion project={pid} mr={iid} discussion={discussion_id} body={truncate_body(body)}"
|
|
135
|
+
)
|
|
136
|
+
async with make_client() as client:
|
|
137
|
+
r = await client.post(
|
|
138
|
+
mr_base_url(pid, iid) + f"/discussions/{discussion_id}/notes",
|
|
139
|
+
json={"body": body},
|
|
140
|
+
)
|
|
141
|
+
if r.status_code in (401, 403):
|
|
142
|
+
fatal(f"reply_to_discussion: HTTP {r.status_code} - token lacks permission to post comments")
|
|
143
|
+
r.raise_for_status()
|
|
144
|
+
|
|
145
|
+
result = {"note_id": r.json()["id"]}
|
|
146
|
+
log.info(f"reply_to_discussion -> {result}")
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def react_to_comment(
|
|
151
|
+
note_id: int,
|
|
152
|
+
emoji: Emoji,
|
|
153
|
+
project_id: str | None = None,
|
|
154
|
+
mr_iid: int | None = None,
|
|
155
|
+
) -> dict[str, Any]:
|
|
156
|
+
"""
|
|
157
|
+
Add an emoji reaction to an existing MR note. Use sparingly - only when a
|
|
158
|
+
simple acknowledgement genuinely adds value. Do not react to every comment.
|
|
159
|
+
|
|
160
|
+
Arguments:
|
|
161
|
+
note_id - the note ID returned by list_discussions.
|
|
162
|
+
emoji - one of the allowed emoji shortcode names (see type annotation).
|
|
163
|
+
project_id - numeric ID or namespace/repo slug. Defaults to CI_PROJECT_ID env var.
|
|
164
|
+
mr_iid - MR internal ID. Defaults to CI_MERGE_REQUEST_IID env var.
|
|
165
|
+
|
|
166
|
+
Returns the created award ID.
|
|
167
|
+
"""
|
|
168
|
+
pid, iid = validate_gitlab_args(project_id, mr_iid)
|
|
169
|
+
log.info(f"react_to_comment project={pid} mr={iid} note={note_id} emoji={emoji}")
|
|
170
|
+
async with make_client() as client:
|
|
171
|
+
r = await client.post(
|
|
172
|
+
mr_base_url(pid, iid) + f"/notes/{note_id}/award_emoji",
|
|
173
|
+
json={"name": emoji},
|
|
174
|
+
)
|
|
175
|
+
if r.status_code in (401, 403):
|
|
176
|
+
fatal(f"react_to_comment: HTTP {r.status_code} - token lacks permission to react to comments")
|
|
177
|
+
r.raise_for_status()
|
|
178
|
+
|
|
179
|
+
result = {"award_id": r.json()["id"]}
|
|
180
|
+
log.info(f"react_to_comment -> {result}")
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
TOOLS = [post_inline_comment, post_summary_comment, reply_to_discussion, react_to_comment]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: patlab-mcp
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Requires-Python: >=3.11
|
|
5
|
+
Requires-Dist: mcp>=1.9
|
|
6
|
+
Requires-Dist: httpx>=0.28
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
9
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
10
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
11
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
12
|
+
Requires-Dist: ruff>=0.9; extra == "dev"
|
|
13
|
+
Requires-Dist: ty>=0.0.1a0; extra == "dev"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
patlab_mcp/__init__.py
|
|
3
|
+
patlab_mcp/client.py
|
|
4
|
+
patlab_mcp/server.py
|
|
5
|
+
patlab_mcp.egg-info/PKG-INFO
|
|
6
|
+
patlab_mcp.egg-info/SOURCES.txt
|
|
7
|
+
patlab_mcp.egg-info/dependency_links.txt
|
|
8
|
+
patlab_mcp.egg-info/entry_points.txt
|
|
9
|
+
patlab_mcp.egg-info/requires.txt
|
|
10
|
+
patlab_mcp.egg-info/top_level.txt
|
|
11
|
+
patlab_mcp/tools/__init__.py
|
|
12
|
+
patlab_mcp/tools/helpers.py
|
|
13
|
+
patlab_mcp/tools/read.py
|
|
14
|
+
patlab_mcp/tools/write.py
|
|
15
|
+
tests/test_read.py
|
|
16
|
+
tests/test_write.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
patlab_mcp
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "patlab-mcp"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"mcp>=1.9",
|
|
11
|
+
"httpx>=0.28",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
patlab-mcp = "patlab_mcp.server:main"
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
where = ["."]
|
|
19
|
+
include = ["patlab_mcp*"]
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
asyncio_mode = "auto"
|
|
23
|
+
testpaths = ["tests"]
|
|
24
|
+
addopts = "--cov=patlab_mcp --cov-report=term-missing"
|
|
25
|
+
|
|
26
|
+
[tool.coverage.run]
|
|
27
|
+
source = ["patlab_mcp"]
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
line-length = 110
|
|
31
|
+
|
|
32
|
+
[tool.ruff.lint]
|
|
33
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
34
|
+
|
|
35
|
+
[tool.ty.environment]
|
|
36
|
+
python-version = "3.11"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest>=8",
|
|
41
|
+
"pytest-asyncio>=0.24",
|
|
42
|
+
"pytest-cov>=5",
|
|
43
|
+
"respx>=0.21",
|
|
44
|
+
"ruff>=0.9",
|
|
45
|
+
"ty>=0.0.1a0",
|
|
46
|
+
]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import respx
|
|
2
|
+
from httpx import Response
|
|
3
|
+
|
|
4
|
+
from patlab_mcp.tools.read import get_mr_diff, get_mr_info, list_discussions
|
|
5
|
+
from tests.conftest import API_URL, MR_IID, MR_PAYLOAD, MR_URL, PROJECT_ID
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@respx.mock
|
|
9
|
+
async def test_get_mr_info_returns_expected_fields():
|
|
10
|
+
respx.get(MR_URL).mock(return_value=Response(200, json=MR_PAYLOAD))
|
|
11
|
+
|
|
12
|
+
result = await get_mr_info(project_id=PROJECT_ID, mr_iid=int(MR_IID))
|
|
13
|
+
|
|
14
|
+
assert result["iid"] == 42
|
|
15
|
+
assert result["title"] == "Add feature X"
|
|
16
|
+
assert result["author"] == "dev"
|
|
17
|
+
assert result["diff_refs"] == MR_PAYLOAD["diff_refs"]
|
|
18
|
+
assert result["source_branch"] == "feature/x"
|
|
19
|
+
assert result["target_branch"] == "main"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@respx.mock
|
|
23
|
+
async def test_get_mr_info_slug_is_url_encoded():
|
|
24
|
+
slug = "mygroup/myrepo"
|
|
25
|
+
encoded_url = f"{API_URL}/projects/mygroup%2Fmyrepo/merge_requests/{MR_IID}"
|
|
26
|
+
respx.get(encoded_url).mock(return_value=Response(200, json=MR_PAYLOAD))
|
|
27
|
+
|
|
28
|
+
result = await get_mr_info(project_id=slug, mr_iid=int(MR_IID))
|
|
29
|
+
|
|
30
|
+
assert result["iid"] == 42
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@respx.mock
|
|
34
|
+
async def test_get_mr_info_always_refreshes_cache():
|
|
35
|
+
route = respx.get(MR_URL).mock(return_value=Response(200, json=MR_PAYLOAD))
|
|
36
|
+
|
|
37
|
+
await get_mr_info(project_id=PROJECT_ID, mr_iid=int(MR_IID))
|
|
38
|
+
await get_mr_info(project_id=PROJECT_ID, mr_iid=int(MR_IID))
|
|
39
|
+
|
|
40
|
+
assert route.call_count == 2
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@respx.mock
|
|
44
|
+
async def test_get_mr_info_none_description_becomes_empty_string():
|
|
45
|
+
payload = {**MR_PAYLOAD, "description": None}
|
|
46
|
+
respx.get(MR_URL).mock(return_value=Response(200, json=payload))
|
|
47
|
+
|
|
48
|
+
result = await get_mr_info(project_id=PROJECT_ID, mr_iid=int(MR_IID))
|
|
49
|
+
|
|
50
|
+
assert result["description"] == ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@respx.mock
|
|
54
|
+
async def test_get_mr_info_milestone_title_extracted():
|
|
55
|
+
payload = {**MR_PAYLOAD, "milestone": {"title": "v2.0"}}
|
|
56
|
+
respx.get(MR_URL).mock(return_value=Response(200, json=payload))
|
|
57
|
+
|
|
58
|
+
result = await get_mr_info(project_id=PROJECT_ID, mr_iid=int(MR_IID))
|
|
59
|
+
|
|
60
|
+
assert result["milestone"] == "v2.0"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@respx.mock
|
|
64
|
+
async def test_get_mr_diff_returns_file_list():
|
|
65
|
+
diffs = [
|
|
66
|
+
{
|
|
67
|
+
"old_path": "foo.py",
|
|
68
|
+
"new_path": "foo.py",
|
|
69
|
+
"new_file": False,
|
|
70
|
+
"deleted_file": False,
|
|
71
|
+
"renamed_file": False,
|
|
72
|
+
"diff": "@@ -1,2 +1,3 @@\n def foo():\n- pass\n+ return 1\n",
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
respx.get(f"{MR_URL}/diffs", params={"per_page": 100, "page": 1}).mock(
|
|
76
|
+
return_value=Response(200, json=diffs)
|
|
77
|
+
)
|
|
78
|
+
respx.get(f"{MR_URL}/diffs", params={"per_page": 100, "page": 2}).mock(
|
|
79
|
+
return_value=Response(200, json=[])
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
result = await get_mr_diff(project_id=PROJECT_ID, mr_iid=int(MR_IID))
|
|
83
|
+
|
|
84
|
+
assert len(result) == 1
|
|
85
|
+
assert result[0]["new_path"] == "foo.py"
|
|
86
|
+
assert "@@ -1,2 +1,3 @@" in result[0]["diff"]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@respx.mock
|
|
90
|
+
async def test_get_mr_diff_paginates_until_empty():
|
|
91
|
+
def make_diff(path: str) -> dict:
|
|
92
|
+
return {
|
|
93
|
+
"old_path": path,
|
|
94
|
+
"new_path": path,
|
|
95
|
+
"new_file": False,
|
|
96
|
+
"deleted_file": False,
|
|
97
|
+
"renamed_file": False,
|
|
98
|
+
"diff": f"@@ -1 +1 @@\n {path}\n",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
respx.get(f"{MR_URL}/diffs", params={"per_page": 100, "page": 1}).mock(
|
|
102
|
+
return_value=Response(200, json=[make_diff("a.py"), make_diff("b.py")])
|
|
103
|
+
)
|
|
104
|
+
respx.get(f"{MR_URL}/diffs", params={"per_page": 100, "page": 2}).mock(
|
|
105
|
+
return_value=Response(200, json=[make_diff("c.py")])
|
|
106
|
+
)
|
|
107
|
+
respx.get(f"{MR_URL}/diffs", params={"per_page": 100, "page": 3}).mock(
|
|
108
|
+
return_value=Response(200, json=[])
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
result = await get_mr_diff(project_id=PROJECT_ID, mr_iid=int(MR_IID))
|
|
112
|
+
|
|
113
|
+
assert len(result) == 3
|
|
114
|
+
assert [f["new_path"] for f in result] == ["a.py", "b.py", "c.py"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@respx.mock
|
|
118
|
+
async def test_list_discussions_paginates_until_empty():
|
|
119
|
+
page1 = [
|
|
120
|
+
{
|
|
121
|
+
"id": "abc",
|
|
122
|
+
"resolved": False,
|
|
123
|
+
"notes": [
|
|
124
|
+
{
|
|
125
|
+
"id": 1,
|
|
126
|
+
"author": {"username": "alice"},
|
|
127
|
+
"body": "looks good",
|
|
128
|
+
"created_at": "2024-01-01",
|
|
129
|
+
"resolvable": False,
|
|
130
|
+
"resolved": False,
|
|
131
|
+
"position": None,
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
]
|
|
136
|
+
respx.get(f"{MR_URL}/discussions", params={"per_page": 100, "page": 1}).mock(
|
|
137
|
+
return_value=Response(200, json=page1)
|
|
138
|
+
)
|
|
139
|
+
respx.get(f"{MR_URL}/discussions", params={"per_page": 100, "page": 2}).mock(
|
|
140
|
+
return_value=Response(200, json=[])
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
result = await list_discussions(project_id=PROJECT_ID, mr_iid=int(MR_IID))
|
|
144
|
+
|
|
145
|
+
assert len(result) == 1
|
|
146
|
+
assert result[0]["id"] == "abc"
|
|
147
|
+
assert result[0]["notes"][0]["author"] == "alice"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@respx.mock
|
|
151
|
+
async def test_list_discussions_exposes_position_for_inline_notes():
|
|
152
|
+
position = {"new_path": "foo.py", "new_line": 5}
|
|
153
|
+
page1 = [
|
|
154
|
+
{
|
|
155
|
+
"id": "xyz",
|
|
156
|
+
"resolved": False,
|
|
157
|
+
"notes": [
|
|
158
|
+
{
|
|
159
|
+
"id": 2,
|
|
160
|
+
"author": {"username": "bob"},
|
|
161
|
+
"body": "why?",
|
|
162
|
+
"created_at": "2024-01-02",
|
|
163
|
+
"resolvable": True,
|
|
164
|
+
"resolved": False,
|
|
165
|
+
"position": position,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
]
|
|
170
|
+
respx.get(f"{MR_URL}/discussions", params={"per_page": 100, "page": 1}).mock(
|
|
171
|
+
return_value=Response(200, json=page1)
|
|
172
|
+
)
|
|
173
|
+
respx.get(f"{MR_URL}/discussions", params={"per_page": 100, "page": 2}).mock(
|
|
174
|
+
return_value=Response(200, json=[])
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
result = await list_discussions(project_id=PROJECT_ID, mr_iid=int(MR_IID))
|
|
178
|
+
|
|
179
|
+
assert result[0]["notes"][0]["position"] == position
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import pytest
|
|
6
|
+
import respx
|
|
7
|
+
from httpx import Response
|
|
8
|
+
|
|
9
|
+
from patlab_mcp.tools.helpers import suggestion_fence, truncate_body
|
|
10
|
+
from patlab_mcp.tools.write import (
|
|
11
|
+
post_inline_comment,
|
|
12
|
+
post_summary_comment,
|
|
13
|
+
react_to_comment,
|
|
14
|
+
reply_to_discussion,
|
|
15
|
+
)
|
|
16
|
+
from tests.conftest import MR_IID, MR_PAYLOAD, MR_URL, PROJECT_ID
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_suggestion_fence_single_line():
|
|
20
|
+
assert suggestion_fence("fixed()\n", 5, None) == "```suggestion:-0+0\nfixed()\n```"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_suggestion_fence_multi_line():
|
|
24
|
+
assert suggestion_fence("a\nb\n", 3, 5) == "```suggestion:-0+2\na\nb\n```"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_suggestion_fence_normalizes_missing_trailing_newline():
|
|
28
|
+
assert suggestion_fence("fixed()", 5, None) == "```suggestion:-0+0\nfixed()\n```"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_suggestion_fence_raises_on_inverted_range():
|
|
32
|
+
with pytest.raises(ValueError, match="new_line_end"):
|
|
33
|
+
suggestion_fence("x\n", new_line=10, new_line_end=5)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_truncate_body_short():
|
|
37
|
+
assert truncate_body("hello") == "'hello'"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_truncate_body_long():
|
|
41
|
+
body = "a" * 61 + "b" * 20
|
|
42
|
+
result = truncate_body(body)
|
|
43
|
+
assert result.startswith("'aaa")
|
|
44
|
+
assert "..." in result
|
|
45
|
+
assert result.endswith("bbbb'")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@respx.mock
|
|
49
|
+
async def test_post_inline_comment_suggestion_builds_body():
|
|
50
|
+
respx.get(MR_URL).mock(return_value=Response(200, json=MR_PAYLOAD))
|
|
51
|
+
discussion = {"id": "disc-s1", "notes": [{"id": 20}]}
|
|
52
|
+
route = respx.post(f"{MR_URL}/discussions").mock(return_value=Response(201, json=discussion))
|
|
53
|
+
|
|
54
|
+
await post_inline_comment(
|
|
55
|
+
new_path="foo.py",
|
|
56
|
+
new_line=5,
|
|
57
|
+
body="nit: prefer this",
|
|
58
|
+
suggestion="return x + 1\n",
|
|
59
|
+
project_id=PROJECT_ID,
|
|
60
|
+
mr_iid=int(MR_IID),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
body = json.loads(route.calls[0].request.content)["body"]
|
|
64
|
+
assert body.startswith("```suggestion:-0+0\n")
|
|
65
|
+
assert "return x + 1" in body
|
|
66
|
+
assert "nit: prefer this" in body
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@respx.mock
|
|
70
|
+
async def test_post_inline_comment_suggestion_no_prose():
|
|
71
|
+
respx.get(MR_URL).mock(return_value=Response(200, json=MR_PAYLOAD))
|
|
72
|
+
discussion = {"id": "disc-s2", "notes": [{"id": 21}]}
|
|
73
|
+
route = respx.post(f"{MR_URL}/discussions").mock(return_value=Response(201, json=discussion))
|
|
74
|
+
|
|
75
|
+
await post_inline_comment(
|
|
76
|
+
new_path="foo.py",
|
|
77
|
+
new_line=5,
|
|
78
|
+
body="",
|
|
79
|
+
suggestion="return x + 1\n",
|
|
80
|
+
project_id=PROJECT_ID,
|
|
81
|
+
mr_iid=int(MR_IID),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
body = json.loads(route.calls[0].request.content)["body"]
|
|
85
|
+
assert body == "```suggestion:-0+0\nreturn x + 1\n```"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@respx.mock
|
|
89
|
+
async def test_post_inline_comment_uses_diff_refs():
|
|
90
|
+
respx.get(MR_URL).mock(return_value=Response(200, json=MR_PAYLOAD))
|
|
91
|
+
discussion = {"id": "disc-1", "notes": [{"id": 10}]}
|
|
92
|
+
route = respx.post(f"{MR_URL}/discussions").mock(return_value=Response(201, json=discussion))
|
|
93
|
+
|
|
94
|
+
result = await post_inline_comment(
|
|
95
|
+
new_path="foo.py", new_line=5, body="fix this", project_id=PROJECT_ID, mr_iid=int(MR_IID)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
assert result == {"discussion_id": "disc-1", "note_id": 10}
|
|
99
|
+
payload = route.calls[0].request
|
|
100
|
+
|
|
101
|
+
body = json.loads(payload.content)
|
|
102
|
+
assert body["position"]["base_sha"] == "aaaa"
|
|
103
|
+
assert body["position"]["head_sha"] == "bbbb"
|
|
104
|
+
assert body["position"]["start_sha"] == "cccc"
|
|
105
|
+
assert body["position"]["new_path"] == "foo.py"
|
|
106
|
+
assert body["position"]["new_line"] == 5
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@respx.mock
|
|
110
|
+
async def test_post_inline_comment_old_path_defaults_to_new_path():
|
|
111
|
+
respx.get(MR_URL).mock(return_value=Response(200, json=MR_PAYLOAD))
|
|
112
|
+
discussion = {"id": "disc-2", "notes": [{"id": 11}]}
|
|
113
|
+
route = respx.post(f"{MR_URL}/discussions").mock(return_value=Response(201, json=discussion))
|
|
114
|
+
|
|
115
|
+
await post_inline_comment(
|
|
116
|
+
new_path="bar.py", new_line=3, body="nit", project_id=PROJECT_ID, mr_iid=int(MR_IID)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
body = json.loads(route.calls[0].request.content)
|
|
120
|
+
assert body["position"]["old_path"] == "bar.py"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@respx.mock
|
|
124
|
+
async def test_post_inline_comment_renamed_file_uses_old_path():
|
|
125
|
+
respx.get(MR_URL).mock(return_value=Response(200, json=MR_PAYLOAD))
|
|
126
|
+
discussion = {"id": "disc-3", "notes": [{"id": 12}]}
|
|
127
|
+
route = respx.post(f"{MR_URL}/discussions").mock(return_value=Response(201, json=discussion))
|
|
128
|
+
|
|
129
|
+
await post_inline_comment(
|
|
130
|
+
new_path="new.py",
|
|
131
|
+
new_line=1,
|
|
132
|
+
body="nit",
|
|
133
|
+
old_path="old.py",
|
|
134
|
+
project_id=PROJECT_ID,
|
|
135
|
+
mr_iid=int(MR_IID),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
body = json.loads(route.calls[0].request.content)
|
|
139
|
+
assert body["position"]["old_path"] == "old.py"
|
|
140
|
+
assert body["position"]["new_path"] == "new.py"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@respx.mock
|
|
144
|
+
async def test_post_inline_comment_fetches_diff_refs_once_across_multiple_calls():
|
|
145
|
+
"""Write tools use the cache (no force), so multiple inline comments only fetch the MR once."""
|
|
146
|
+
mr_route = respx.get(MR_URL).mock(return_value=Response(200, json=MR_PAYLOAD))
|
|
147
|
+
discussion = {"id": "disc-4", "notes": [{"id": 13}]}
|
|
148
|
+
respx.post(f"{MR_URL}/discussions").mock(return_value=Response(201, json=discussion))
|
|
149
|
+
|
|
150
|
+
await post_inline_comment(
|
|
151
|
+
new_path="a.py", new_line=1, body="one", project_id=PROJECT_ID, mr_iid=int(MR_IID)
|
|
152
|
+
)
|
|
153
|
+
await post_inline_comment(
|
|
154
|
+
new_path="b.py", new_line=2, body="two", project_id=PROJECT_ID, mr_iid=int(MR_IID)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
assert mr_route.call_count == 1
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@respx.mock
|
|
161
|
+
async def test_post_inline_comment_single_line_has_no_line_range():
|
|
162
|
+
respx.get(MR_URL).mock(return_value=Response(200, json=MR_PAYLOAD))
|
|
163
|
+
discussion = {"id": "disc-5", "notes": [{"id": 14}]}
|
|
164
|
+
route = respx.post(f"{MR_URL}/discussions").mock(return_value=Response(201, json=discussion))
|
|
165
|
+
|
|
166
|
+
await post_inline_comment(
|
|
167
|
+
new_path="foo.py", new_line=5, body="nit", project_id=PROJECT_ID, mr_iid=int(MR_IID)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
body = json.loads(route.calls[0].request.content)
|
|
171
|
+
assert "line_range" not in body["position"]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@respx.mock
|
|
175
|
+
async def test_post_inline_comment_range_sends_line_range():
|
|
176
|
+
respx.get(MR_URL).mock(return_value=Response(200, json=MR_PAYLOAD))
|
|
177
|
+
discussion = {"id": "disc-6", "notes": [{"id": 15}]}
|
|
178
|
+
route = respx.post(f"{MR_URL}/discussions").mock(return_value=Response(201, json=discussion))
|
|
179
|
+
|
|
180
|
+
await post_inline_comment(
|
|
181
|
+
new_path="foo.py",
|
|
182
|
+
new_line=5,
|
|
183
|
+
body="fix this block",
|
|
184
|
+
new_line_end=9,
|
|
185
|
+
project_id=PROJECT_ID,
|
|
186
|
+
mr_iid=int(MR_IID),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
body = json.loads(route.calls[0].request.content)
|
|
190
|
+
position = body["position"]
|
|
191
|
+
assert position["new_line"] == 5
|
|
192
|
+
|
|
193
|
+
lr = position["line_range"]
|
|
194
|
+
assert lr["start"]["new_line"] == 5
|
|
195
|
+
assert lr["start"]["old_line"] == 0
|
|
196
|
+
assert lr["start"]["type"] == "new"
|
|
197
|
+
assert lr["end"]["new_line"] == 9
|
|
198
|
+
assert lr["end"]["old_line"] == 0
|
|
199
|
+
assert lr["end"]["type"] == "new"
|
|
200
|
+
|
|
201
|
+
expected_sha = hashlib.sha1(b"foo.pyfoo.py").hexdigest()
|
|
202
|
+
assert lr["start"]["line_code"] == f"{expected_sha}_0_5"
|
|
203
|
+
assert lr["end"]["line_code"] == f"{expected_sha}_0_9"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@respx.mock
|
|
207
|
+
async def test_post_inline_comment_range_renamed_file_uses_old_path_in_line_code():
|
|
208
|
+
respx.get(MR_URL).mock(return_value=Response(200, json=MR_PAYLOAD))
|
|
209
|
+
discussion = {"id": "disc-7", "notes": [{"id": 16}]}
|
|
210
|
+
route = respx.post(f"{MR_URL}/discussions").mock(return_value=Response(201, json=discussion))
|
|
211
|
+
|
|
212
|
+
await post_inline_comment(
|
|
213
|
+
new_path="new.py",
|
|
214
|
+
new_line=3,
|
|
215
|
+
body="fix range",
|
|
216
|
+
old_path="old.py",
|
|
217
|
+
new_line_end=6,
|
|
218
|
+
project_id=PROJECT_ID,
|
|
219
|
+
mr_iid=int(MR_IID),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
body = json.loads(route.calls[0].request.content)
|
|
223
|
+
expected_sha = hashlib.sha1(b"old.pynew.py").hexdigest()
|
|
224
|
+
lr = body["position"]["line_range"]
|
|
225
|
+
assert lr["start"]["line_code"] == f"{expected_sha}_0_3"
|
|
226
|
+
assert lr["end"]["line_code"] == f"{expected_sha}_0_6"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@respx.mock
|
|
230
|
+
async def test_post_inline_comment_401_writes_sentinel(clean_sentinel):
|
|
231
|
+
respx.get(MR_URL).mock(return_value=Response(200, json=MR_PAYLOAD))
|
|
232
|
+
respx.post(f"{MR_URL}/discussions").mock(return_value=Response(401, json={}))
|
|
233
|
+
|
|
234
|
+
with pytest.raises(httpx.HTTPStatusError):
|
|
235
|
+
await post_inline_comment(
|
|
236
|
+
new_path="foo.py", new_line=1, body="x", project_id=PROJECT_ID, mr_iid=int(MR_IID)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
assert clean_sentinel.exists()
|
|
240
|
+
assert "401" in clean_sentinel.read_text()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@respx.mock
|
|
244
|
+
async def test_post_summary_comment_returns_note_id():
|
|
245
|
+
note = {"id": 99}
|
|
246
|
+
respx.post(f"{MR_URL}/notes").mock(return_value=Response(201, json=note))
|
|
247
|
+
|
|
248
|
+
result = await post_summary_comment("Overall LGTM", project_id=PROJECT_ID, mr_iid=int(MR_IID))
|
|
249
|
+
|
|
250
|
+
assert result == {"note_id": 99}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@respx.mock
|
|
254
|
+
async def test_post_summary_comment_403_writes_sentinel(clean_sentinel):
|
|
255
|
+
respx.post(f"{MR_URL}/notes").mock(return_value=Response(403, json={}))
|
|
256
|
+
|
|
257
|
+
with pytest.raises(httpx.HTTPStatusError):
|
|
258
|
+
await post_summary_comment("summary", project_id=PROJECT_ID, mr_iid=int(MR_IID))
|
|
259
|
+
|
|
260
|
+
assert clean_sentinel.exists()
|
|
261
|
+
assert "403" in clean_sentinel.read_text()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@respx.mock
|
|
265
|
+
async def test_reply_to_discussion_returns_note_id():
|
|
266
|
+
note = {"id": 55}
|
|
267
|
+
respx.post(f"{MR_URL}/discussions/disc-abc/notes").mock(return_value=Response(201, json=note))
|
|
268
|
+
|
|
269
|
+
result = await reply_to_discussion(
|
|
270
|
+
discussion_id="disc-abc", body="good catch, here's an impl", project_id=PROJECT_ID, mr_iid=int(MR_IID)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
assert result == {"note_id": 55}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@respx.mock
|
|
277
|
+
async def test_reply_to_discussion_401_writes_sentinel(clean_sentinel):
|
|
278
|
+
respx.post(f"{MR_URL}/discussions/disc-abc/notes").mock(return_value=Response(401, json={}))
|
|
279
|
+
|
|
280
|
+
with pytest.raises(httpx.HTTPStatusError):
|
|
281
|
+
await reply_to_discussion(
|
|
282
|
+
discussion_id="disc-abc", body="reply", project_id=PROJECT_ID, mr_iid=int(MR_IID)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
assert clean_sentinel.exists()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@respx.mock
|
|
289
|
+
async def test_react_to_comment_returns_award_id():
|
|
290
|
+
award = {"id": 7}
|
|
291
|
+
respx.post(f"{MR_URL}/notes/42/award_emoji").mock(return_value=Response(201, json=award))
|
|
292
|
+
|
|
293
|
+
result = await react_to_comment(note_id=42, emoji="thumbsup", project_id=PROJECT_ID, mr_iid=int(MR_IID))
|
|
294
|
+
|
|
295
|
+
assert result == {"award_id": 7}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@respx.mock
|
|
299
|
+
async def test_react_to_comment_sends_correct_emoji_name():
|
|
300
|
+
award = {"id": 8}
|
|
301
|
+
route = respx.post(f"{MR_URL}/notes/42/award_emoji").mock(return_value=Response(201, json=award))
|
|
302
|
+
|
|
303
|
+
await react_to_comment(note_id=42, emoji="tada", project_id=PROJECT_ID, mr_iid=int(MR_IID))
|
|
304
|
+
|
|
305
|
+
body = json.loads(route.calls[0].request.content)
|
|
306
|
+
assert body["name"] == "tada"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@respx.mock
|
|
310
|
+
async def test_react_to_comment_403_writes_sentinel(clean_sentinel):
|
|
311
|
+
respx.post(f"{MR_URL}/notes/42/award_emoji").mock(return_value=Response(403, json={}))
|
|
312
|
+
|
|
313
|
+
with pytest.raises(httpx.HTTPStatusError):
|
|
314
|
+
await react_to_comment(note_id=42, emoji="fire", project_id=PROJECT_ID, mr_iid=int(MR_IID))
|
|
315
|
+
|
|
316
|
+
assert clean_sentinel.exists()
|