Skip to content

Commit 1fff1b0

Browse files
committed
Merge remote-tracking branch 'origin/main'
2 parents 0989247 + 411f933 commit 1fff1b0

File tree

6 files changed

+325
-48
lines changed

6 files changed

+325
-48
lines changed

docs/docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Qodo Merge is a hosted version of PR-Agent, designed for companies and teams tha
99

1010
- See the [Tools Guide](./tools/index.md) for a detailed description of the different tools.
1111

12-
- See the [Video Tutorials](https://www.youtube.com/playlist?list=PLRTpyDOSgbwFMA_VBeKMnPLaaZKwjGBFT) for practical demonstrations on how to use the tools.
12+
- See the video tutorials [[1](https://www.youtube.com/playlist?list=PLRTpyDOSgbwFMA_VBeKMnPLaaZKwjGBFT), [2](https://www.youtube.com/watch?v=7-yJLd7zu40)] for practical demonstrations on how to use the tools.
1313

1414
## Docs Smart Search
1515

docs/docs/recent_updates/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ It also outlines our development roadmap for the upcoming three months. Please n
88
=== "Recent Updates"
99
| Date | Feature | Description |
1010
|---|---|---|
11+
| 2025-08-27 | **GitLab submodule diff expansion** | Optionally expand GitLab submodule updates into full diffs. ([Learn more](https://qodo-merge-docs.qodo.ai/usage-guide/additional_configurations/#expand-gitlab-submodule-diffs)) |
1112
| 2025-08-11 | **RAG support for GitLab** | All Qodo Merge RAG features are now available in GitLab. ([Learn more](https://qodo-merge-docs.qodo.ai/core-abilities/rag_context_enrichment/)) |
1213
| 2025-07-29 | **High-level Suggestions** | Qodo Merge now also provides high-level code suggestion for PRs. ([Learn more](https://qodo-merge-docs.qodo.ai/core-abilities/high_level_suggestions/)) |
1314
| 2025-07-20 | **PR to Ticket** | Generate tickets in your tracking systems based on PR content. ([Learn more](https://qodo-merge-docs.qodo.ai/tools/pr_to_ticket/)) |

docs/docs/usage-guide/additional_configurations.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ All Qodo Merge tools have a parameter called `extra_instructions`, that enables
6464

6565
## Language Settings
6666

67-
The default response language for Qodo Merge is **U.S. English**. However, some development teams may prefer to display information in a different language. For example, your team's workflow might improve if PR descriptions and code suggestions are set to your country's native language.
67+
The default response language for Qodo Merge is **U.S. English**. However, some development teams may prefer to display information in a different language. For example, your team's workflow might improve if PR descriptions and code suggestions are set to your country's native language.
6868

69-
To configure this, set the `response_language` parameter in the configuration file. This will prompt the model to respond in the specified language. Use a **standard locale code** based on [ISO 3166](https://en.wikipedia.org/wiki/ISO_3166) (country codes) and [ISO 639](https://en.wikipedia.org/wiki/ISO_639) (language codes) to define a language-country pair. See this [comprehensive list of locale codes](https://simplelocalize.io/data/locales/).
69+
To configure this, set the `response_language` parameter in the configuration file. This will prompt the model to respond in the specified language. Use a **standard locale code** based on [ISO 3166](https://en.wikipedia.org/wiki/ISO_3166) (country codes) and [ISO 639](https://en.wikipedia.org/wiki/ISO_639) (language codes) to define a language-country pair. See this [comprehensive list of locale codes](https://simplelocalize.io/data/locales/).
7070

7171
Example:
7272

@@ -98,6 +98,17 @@ This will set the response language globally for all the commands to Italian.
9898
[//]: # (which divides the PR into chunks, and processes each chunk separately. With this mode, regardless of the model, no compression will be done (but for large PRs, multiple model calls may occur))
9999

100100

101+
## Expand GitLab submodule diffs
102+
103+
By default, GitLab merge requests show submodule updates as `Subproject commit` lines. To include the actual file-level changes from those submodules in Qodo Merge analysis, enable:
104+
105+
```toml
106+
[gitlab]
107+
expand_submodule_diffs = true
108+
```
109+
110+
When enabled, Qodo Merge will fetch and attach diffs from the submodule repositories. The default is `false` to avoid extra GitLab API calls.
111+
101112
## Log Level
102113

103114
Qodo Merge allows you to control the verbosity of logging by using the `log_level` configuration parameter. This is particularly useful for troubleshooting and debugging issues with your PR workflows.
@@ -254,7 +265,7 @@ To automatically exclude files generated by specific languages or frameworks, yo
254265
ignore_language_framework = ['protobuf', ...]
255266
```
256267

257-
You can view the list of auto-generated file patterns in [`generated_code_ignore.toml`](https://github.com/qodo-ai/pr-agent/blob/main/pr_agent/settings/generated_code_ignore.toml).
268+
You can view the list of auto-generated file patterns in [`generated_code_ignore.toml`](https://github.com/qodo-ai/pr-agent/blob/main/pr_agent/settings/generated_code_ignore.toml).
258269
Files matching these glob patterns will be automatically excluded from PR Agent analysis.
259270

260271
### Ignoring Tickets with Specific Labels

pr_agent/git_providers/gitlab_provider.py

Lines changed: 233 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import difflib
22
import hashlib
33
import re
4-
from typing import Optional, Tuple, Any, Union
5-
from urllib.parse import urlparse, parse_qs
4+
import urllib.parse
5+
from typing import Any, Optional, Tuple, Union
6+
from urllib.parse import parse_qs, urlparse
67

78
import gitlab
89
import requests
9-
from gitlab import GitlabGetError, GitlabAuthenticationError, GitlabCreateError, GitlabUpdateError
10+
from gitlab import (GitlabAuthenticationError, GitlabCreateError,
11+
GitlabGetError, GitlabUpdateError)
1012

1113
from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo
1214

@@ -38,12 +40,12 @@ def __init__(self, merge_request_url: Optional[str] = None, incremental: Optiona
3840
raise ValueError("GitLab personal access token is not set in the config file")
3941
# Authentication method selection via configuration
4042
auth_method = get_settings().get("GITLAB.AUTH_TYPE", "oauth_token")
41-
43+
4244
# Basic validation of authentication type
4345
if auth_method not in ["oauth_token", "private_token"]:
4446
raise ValueError(f"Unsupported GITLAB.AUTH_TYPE: '{auth_method}'. "
4547
f"Must be 'oauth_token' or 'private_token'.")
46-
48+
4749
# Create GitLab instance based on authentication method
4850
try:
4951
if auth_method == "oauth_token":
@@ -67,12 +69,221 @@ def __init__(self, merge_request_url: Optional[str] = None, incremental: Optiona
6769
self.diff_files = None
6870
self.git_files = None
6971
self.temp_comments = []
72+
self._submodule_cache: dict[tuple[str, str, str], list[dict]] = {}
7073
self.pr_url = merge_request_url
7174
self._set_merge_request(merge_request_url)
7275
self.RE_HUNK_HEADER = re.compile(
7376
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)")
7477
self.incremental = incremental
7578

79+
# --- submodule expansion helpers (opt-in) ---
80+
def _get_gitmodules_map(self) -> dict[str, str]:
81+
"""
82+
Return {submodule_path -> repo_url} from '.gitmodules' (best effort).
83+
Tries target branch first, then source branch. Always returns text.
84+
"""
85+
try:
86+
proj = self.gl.projects.get(self.id_project)
87+
except Exception:
88+
return {}
89+
90+
import base64
91+
92+
def _read_text(ref: str | None) -> str | None:
93+
if not ref:
94+
return None
95+
try:
96+
f = proj.files.get(file_path=".gitmodules", ref=ref)
97+
except Exception:
98+
return None
99+
100+
# 1) python-gitlab File.decode() – usually returns BYTES
101+
try:
102+
raw = f.decode()
103+
if isinstance(raw, (bytes, bytearray)):
104+
return raw.decode("utf-8", "ignore")
105+
if isinstance(raw, str):
106+
return raw
107+
except Exception:
108+
pass
109+
110+
# 2) fallback: base64 decode f.content
111+
try:
112+
c = getattr(f, "content", None)
113+
if c:
114+
return base64.b64decode(c).decode("utf-8", "ignore")
115+
except Exception:
116+
pass
117+
118+
return None
119+
120+
content = (
121+
_read_text(getattr(self.mr, "target_branch", None))
122+
or _read_text(getattr(self.mr, "source_branch", None))
123+
)
124+
if not content:
125+
return {}
126+
127+
import configparser
128+
129+
parser = configparser.ConfigParser(
130+
delimiters=("=",),
131+
interpolation=None,
132+
inline_comment_prefixes=("#", ";"),
133+
strict=False,
134+
)
135+
try:
136+
parser.read_string(content)
137+
except Exception:
138+
return {}
139+
140+
out: dict[str, str] = {}
141+
for section in parser.sections():
142+
if not section.lower().startswith("submodule"):
143+
continue
144+
path = parser.get(section, "path", fallback=None)
145+
url = parser.get(section, "url", fallback=None)
146+
if path and url:
147+
path = path.strip().strip('"').strip("'")
148+
url = url.strip().strip('"').strip("'")
149+
out[path] = url
150+
return out
151+
152+
def _url_to_project_path(self, url: str) -> str | None:
153+
"""
154+
Convert ssh/https GitLab URL to 'group/subgroup/repo' project path.
155+
"""
156+
try:
157+
if url.startswith("git@") and ":" in url:
158+
path = url.split(":", 1)[1]
159+
else:
160+
path = urllib.parse.urlparse(url).path.lstrip("/")
161+
if path.endswith(".git"):
162+
path = path[:-4]
163+
return path or None
164+
except Exception:
165+
return None
166+
167+
def _project_by_path(self, proj_path: str):
168+
"""
169+
Resolve a project by path with multiple strategies:
170+
1) URL-encoded path_with_namespace
171+
2) Raw path_with_namespace
172+
3) Search fallback + exact match on path_with_namespace (case-insensitive)
173+
Returns a project object or None.
174+
"""
175+
if not proj_path:
176+
return None
177+
178+
# 1) Encoded
179+
try:
180+
enc = urllib.parse.quote_plus(proj_path)
181+
return self.gl.projects.get(enc)
182+
except Exception:
183+
pass
184+
185+
# 2) Raw
186+
try:
187+
return self.gl.projects.get(proj_path)
188+
except Exception:
189+
pass
190+
191+
# 3) Search fallback
192+
try:
193+
name = proj_path.split("/")[-1]
194+
# membership=True so we don't leak other people's repos
195+
matches = self.gl.projects.list(search=name, simple=True, membership=True, per_page=100)
196+
# prefer exact path_with_namespace match (case-insensitive)
197+
for p in matches:
198+
pwn = getattr(p, "path_with_namespace", "")
199+
if pwn.lower() == proj_path.lower():
200+
return self.gl.projects.get(p.id)
201+
if matches:
202+
get_logger().warning(f"[submodule] no exact match for {proj_path} (skip)")
203+
except Exception:
204+
pass
205+
206+
return None
207+
208+
def _compare_submodule(self, proj_path: str, old_sha: str, new_sha: str) -> list[dict]:
209+
"""
210+
Call repository_compare on submodule project; return list of diffs.
211+
"""
212+
key = (proj_path, old_sha, new_sha)
213+
if key in self._submodule_cache:
214+
return self._submodule_cache[key]
215+
try:
216+
proj = self._project_by_path(proj_path)
217+
if proj is None:
218+
get_logger().warning(f"[submodule] resolve failed for {proj_path}")
219+
self._submodule_cache[key] = []
220+
return []
221+
cmp = proj.repository_compare(old_sha, new_sha)
222+
if isinstance(cmp, dict):
223+
diffs = cmp.get("diffs", []) or []
224+
else:
225+
diffs = []
226+
self._submodule_cache[key] = diffs
227+
return diffs
228+
except Exception as e:
229+
get_logger().warning(f"[submodule] compare failed for {proj_path} {old_sha}..{new_sha}: {e}")
230+
self._submodule_cache[key] = []
231+
return []
232+
233+
def _expand_submodule_changes(self, changes: list[dict]) -> list[dict]:
234+
"""
235+
If enabled, expand 'Subproject commit' bumps into real file diffs from the submodule.
236+
Soft-fail on any issue.
237+
"""
238+
try:
239+
if not bool(get_settings().get("GITLAB.EXPAND_SUBMODULE_DIFFS", False)):
240+
return changes
241+
except Exception:
242+
return changes
243+
244+
gitmodules = self._get_gitmodules_map()
245+
if not gitmodules:
246+
return changes
247+
248+
out = list(changes)
249+
for ch in changes:
250+
patch = ch.get("diff") or ""
251+
if "Subproject commit" not in patch:
252+
continue
253+
254+
# Extract old/new SHAs from the hunk
255+
old_m = re.search(r"^-Subproject commit ([0-9a-f]{7,40})", patch, re.M)
256+
new_m = re.search(r"^\+Subproject commit ([0-9a-f]{7,40})", patch, re.M)
257+
if not (old_m and new_m):
258+
continue
259+
old_sha, new_sha = old_m.group(1), new_m.group(1)
260+
261+
sub_path = ch.get("new_path") or ch.get("old_path") or ""
262+
repo_url = gitmodules.get(sub_path)
263+
if not repo_url:
264+
get_logger().warning(f"[submodule] no url for '{sub_path}' in .gitmodules (skip)")
265+
continue
266+
267+
proj_path = self._url_to_project_path(repo_url)
268+
if not proj_path:
269+
get_logger().warning(f"[submodule] cannot parse project path from url '{repo_url}' (skip)")
270+
continue
271+
272+
get_logger().info(f"[submodule] {sub_path} url={repo_url} -> proj_path={proj_path}")
273+
sub_diffs = self._compare_submodule(proj_path, old_sha, new_sha)
274+
for sd in sub_diffs:
275+
sd_diff = sd.get("diff") or ""
276+
sd_old = sd.get("old_path") or sd.get("a_path") or ""
277+
sd_new = sd.get("new_path") or sd.get("b_path") or sd_old
278+
out.append({
279+
"old_path": f"{sub_path}/{sd_old}" if sd_old else sub_path,
280+
"new_path": f"{sub_path}/{sd_new}" if sd_new else sub_path,
281+
"diff": sd_diff,
282+
"new_file": sd.get("new_file", False),
283+
"deleted_file": sd.get("deleted_file", False),
284+
"renamed_file": sd.get("renamed_file", False),
285+
})
286+
return out
76287

77288
def is_supported(self, capability: str) -> bool:
78289
if capability in ['get_issue_comments', 'create_inline_comment', 'publish_inline_comments',
@@ -152,11 +363,11 @@ def create_or_update_pr_file(self, file_path: str, branch: str, contents="", mes
152363
"""Create or update a file in the GitLab repository."""
153364
try:
154365
project = self.gl.projects.get(self.id_project)
155-
366+
156367
if not message:
157368
action = "Update" if contents else "Create"
158369
message = f"{action} {file_path}"
159-
370+
160371
try:
161372
existing_file = project.files.get(file_path, branch)
162373
existing_file.content = contents
@@ -194,7 +405,9 @@ def get_diff_files(self) -> list[FilePatchInfo]:
194405
return self.diff_files
195406

196407
# filter files using [ignore] patterns
197-
diffs_original = self.mr.changes()['changes']
408+
raw_changes = self.mr.changes().get('changes', [])
409+
raw_changes = self._expand_submodule_changes(raw_changes)
410+
diffs_original = raw_changes
198411
diffs = filter_ignored(diffs_original, 'gitlab')
199412
if diffs != diffs_original:
200413
try:
@@ -264,7 +477,9 @@ def get_diff_files(self) -> list[FilePatchInfo]:
264477

265478
def get_files(self) -> list:
266479
if not self.git_files:
267-
self.git_files = [change['new_path'] for change in self.mr.changes()['changes']]
480+
raw_changes = self.mr.changes().get('changes', [])
481+
raw_changes = self._expand_submodule_changes(raw_changes)
482+
self.git_files = [c.get('new_path') for c in raw_changes if c.get('new_path')]
268483
return self.git_files
269484

270485
def publish_description(self, pr_title: str, pr_body: str):
@@ -420,7 +635,9 @@ def send_inline_comment(self, body: str, edit_type: str, found: bool, relevant_f
420635
get_logger().exception(f"Failed to create comment in MR {self.id_mr}")
421636

422637
def get_relevant_diff(self, relevant_file: str, relevant_line_in_file: str) -> Optional[dict]:
423-
changes = self.mr.changes() # Retrieve the changes for the merge request once
638+
_changes = self.mr.changes() # dict
639+
_changes['changes'] = self._expand_submodule_changes(_changes.get('changes', []))
640+
changes = _changes
424641
if not changes:
425642
get_logger().error('No changes found for the merge request.')
426643
return None
@@ -589,14 +806,14 @@ def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -
589806
if not self.id_mr:
590807
get_logger().warning("Cannot add eyes reaction: merge request ID is not set.")
591808
return None
592-
809+
593810
mr = self.gl.projects.get(self.id_project).mergerequests.get(self.id_mr)
594811
comment = mr.notes.get(issue_comment_id)
595-
812+
596813
if not comment:
597814
get_logger().warning(f"Comment with ID {issue_comment_id} not found in merge request {self.id_mr}.")
598815
return None
599-
816+
600817
award_emoji = comment.awardemojis.create({
601818
'name': 'eyes'
602819
})
@@ -610,20 +827,20 @@ def remove_reaction(self, issue_comment_id: int, reaction_id: str) -> bool:
610827
if not self.id_mr:
611828
get_logger().warning("Cannot remove reaction: merge request ID is not set.")
612829
return False
613-
830+
614831
mr = self.gl.projects.get(self.id_project).mergerequests.get(self.id_mr)
615832
comment = mr.notes.get(issue_comment_id)
616833

617834
if not comment:
618835
get_logger().warning(f"Comment with ID {issue_comment_id} not found in merge request {self.id_mr}.")
619836
return False
620-
837+
621838
reactions = comment.awardemojis.list()
622839
for reaction in reactions:
623840
if reaction.name == reaction_id:
624841
reaction.delete()
625842
return True
626-
843+
627844
get_logger().warning(f"Reaction '{reaction_id}' not found in comment {issue_comment_id}.")
628845
return False
629846
except Exception as e:

0 commit comments

Comments
 (0)