Skip to content

Commit bad3bbf

Browse files
committed
Fixed multiple pipeline being fixed simultaneously.
1 parent b0e40b8 commit bad3bbf

File tree

15 files changed

+243
-106
lines changed

15 files changed

+243
-106
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- GitLab client now `retry_transient_errors=True` to be more resilient to transient errors.
2222
- Improved assessment of request for changes on `ReviewAddressorAgent` to allow the agent to reason before responding.
2323
- Improved response prompt for `ReviewAddressorAgent` to avoid answering like "I'll update the code to replace", as the agent is not able to update the code but only answer questions.
24+
- Configuration of webhooks now takes a default `EXTERNAL_URL` for `--base-url` to avoid having to pass it on every call, and now configures `pipeline_events` instead of `job_events`. **You must run command `setup_webhooks` to update the webhooks on your GitLab projects.**
25+
- Turn `PullRequestDescriberAgent` more resilient to errors by defining fallbacks to use a model from other provider.
2426

2527
### Fixed
2628

2729
- Fixed index updates to branches that don't exist anymore, like when a branch is marked to delete after a merge request is merged: #153.
30+
- `SnippetReplacerAgent` was replacing multiple snippets when only one was expected. Now it will return an error if multiple snippets are found to instruct the llm to provide a more specific original snippet.
31+
- `PipelineFixerAgent` was trying to fix multiple jobs from the same stage at the same time, causing multiple fixes being applied simultaneously to the same files which could lead to conflicts or a job being fixed with outdated code. Now it will fix one job at a time. #164
32+
33+
### Removed
34+
35+
- `get_repository_tree` was removed from the `RepoClient` as it's no longer used.
2836

2937
## [0.1.0-alpha.15] - 2024-12-30
3038

daiv/automation/agents/pr_describer/agent.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from langchain_core.runnables import Runnable
55

66
from automation.agents import BaseAgent
7-
from automation.agents.base import GENERIC_COST_EFFICIENT_MODEL_NAME
7+
from automation.agents.base import CODING_COST_EFFICIENT_MODEL_NAME, GENERIC_COST_EFFICIENT_MODEL_NAME
88
from codebase.base import FileChange
99

1010
from .prompts import human, system
@@ -28,4 +28,8 @@ def compile(self) -> Runnable:
2828
prompt = ChatPromptTemplate.from_messages([system, human]).partial(
2929
branch_name_convention=None, extra_details={}
3030
)
31-
return prompt | self.model.with_structured_output(PullRequestDescriberOutput, method="json_schema")
31+
return prompt | self.model.with_structured_output(
32+
PullRequestDescriberOutput, method="json_schema"
33+
).with_fallbacks([
34+
self.get_model(model=CODING_COST_EFFICIENT_MODEL_NAME).with_structured_output(PullRequestDescriberOutput)
35+
])

daiv/automation/agents/snippet_replacer/agent.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ def _replace_content_snippet(self, input_data: SnippetReplacerInput) -> SnippetR
5454
if not original_snippet_found:
5555
return "error: Original snippet not found."
5656

57-
replaced_content = input_data["content"].replace(original_snippet_found, input_data["replacement_snippet"])
57+
if len(original_snippet_found) > 1:
58+
return "error: Multiple original snippets found. Please provide a more specific original snippet."
59+
60+
replaced_content = input_data["content"].replace(original_snippet_found[0], input_data["replacement_snippet"])
5861
if not replaced_content:
5962
return "error: Snippet replacement failed."
6063

daiv/automation/agents/snippet_replacer/utils.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import re
33

44

5-
def find_original_snippet(snippet: str, file_contents: str, threshold=0.8, initial_line_threshold=0.9) -> str | None:
5+
def find_original_snippet(snippet: str, file_contents: str, threshold=0.8, initial_line_threshold=0.9) -> list[str]:
66
"""
77
This function finds the original snippet of code in a file given a snippet and the file contents.
88
@@ -19,18 +19,19 @@ def find_original_snippet(snippet: str, file_contents: str, threshold=0.8, initi
1919
with a line in the file.
2020
2121
Returns:
22-
tuple[str, int, int] | None: A tuple containing the original snippet from the file, start index, and end index,
23-
or None if the snippet could not be found.
22+
list[str]: A list of original snippets from the file.
2423
"""
2524
if snippet.strip() == "":
26-
return None
25+
return []
2726

2827
snippet_lines = [line for line in snippet.split("\n") if line.strip()]
2928
file_lines = file_contents.split("\n")
3029

3130
# Find the first non-empty line in the snippet
3231
first_snippet_line = next((line for line in snippet_lines if line.strip()), "")
3332

33+
all_matches = []
34+
3435
# Search for a matching initial line in the file
3536
for start_index, file_line in enumerate(file_lines):
3637
if compute_similarity(first_snippet_line, file_line) >= initial_line_threshold:
@@ -55,9 +56,9 @@ def find_original_snippet(snippet: str, file_contents: str, threshold=0.8, initi
5556

5657
if snippet_index == len(snippet_lines):
5758
# All lines in the snippet have been matched
58-
return "\n".join(file_lines[start_index:file_index])
59+
all_matches.append("\n".join(file_lines[start_index:file_index]))
5960

60-
return None
61+
return all_matches
6162

6263

6364
def compute_similarity(text1: str, text2: str, ignore_whitespace=True) -> float:
@@ -67,13 +68,13 @@ def compute_similarity(text1: str, text2: str, ignore_whitespace=True) -> float:
6768
difflib.SequenceMatcher uses the Ratcliff/Obershelp algorithm: it computes the doubled number of matching
6869
characters divided by the total number of characters in the two strings.
6970
70-
Parameters:
71-
text1 (str): The first piece of text.
72-
text2 (str): The second piece of text.
73-
ignore_whitespace (bool): If True, ignores whitespace when comparing the two pieces of text.
71+
Args:
72+
text1 (str): The first piece of text.
73+
text2 (str): The second piece of text.
74+
ignore_whitespace (bool): If True, ignores whitespace when comparing the two pieces of text.
7475
7576
Returns:
76-
float: The similarity ratio between the two pieces of text.
77+
float: The similarity ratio between the two pieces of text.
7778
"""
7879
if ignore_whitespace:
7980
text1 = re.sub(r"\s+", "", text1)

daiv/automation/tools/repository.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ class ReplaceSnippetInFileTool(BaseRepositoryTool):
176176
For multiple replacements, call this tool multiple times.
177177
Do not alter indentation levels unless intentionally modifying code block structures.
178178
Inspect the code beforehand to understand what exaclty needs to change.
179+
180+
IMPORTANT:
181+
- Provide at least 3 lines before and 3 lines after the snippet you want to replace.
182+
- Include unique identifiers such as variable names or function calls that appear only once in the entire file.
179183
""" # noqa: E501
180184
)
181185

daiv/codebase/api/callbacks_gitlab.py

Lines changed: 54 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
import logging
2-
import re
32
from functools import cached_property
43
from typing import Any, Literal
54

65
from asgiref.sync import sync_to_async
76

87
from codebase.api.callbacks import BaseCallback
9-
from codebase.api.models import Issue, IssueAction, MergeRequest, Note, NoteableType, NoteAction, Project, User
8+
from codebase.api.models import (
9+
Issue,
10+
IssueAction,
11+
MergeRequest,
12+
Note,
13+
NoteableType,
14+
NoteAction,
15+
Pipeline,
16+
PipelineBuild,
17+
Project,
18+
User,
19+
)
1020
from codebase.base import MergeRequest as BaseMergeRequest
1121
from codebase.clients import RepoClient
1222
from codebase.tasks import address_issue_task, address_review_task, fix_pipeline_job_task, update_index_repository
1323
from core.config import RepositoryConfig
14-
from core.utils import generate_uuid
1524

1625
PIPELINE_JOB_REF_SUFFIX = "refs/merge-requests/"
1726

@@ -176,68 +185,73 @@ def related_merge_requests(self) -> list[BaseMergeRequest]:
176185
return client.get_commit_related_merge_requests(self.project.path_with_namespace, commit_sha=self.checkout_sha)
177186

178187

179-
class PipelineJobCallback(BaseCallback):
188+
class PipelineStatusCallback(BaseCallback):
180189
"""
181-
Gitlab Pipeline Job Webhook
190+
Gitlab Pipeline Status Webhook
182191
"""
183192

184-
object_kind: Literal["build"]
193+
object_kind: Literal["pipeline"]
185194
project: Project
186-
sha: str
187-
ref: str
188-
build_id: int
189-
build_name: str
190-
build_allow_failure: bool
191-
build_status: Literal[
192-
"created", "pending", "running", "failed", "success", "canceled", "skipped", "manual", "scheduled"
193-
]
194-
build_failure_reason: str
195+
merge_request: MergeRequest | None = None
196+
object_attributes: Pipeline
197+
builds: list[PipelineBuild]
195198

196199
def model_post_init(self, __context: Any):
197200
self._repo_config = RepositoryConfig.get_config(self.project.path_with_namespace)
198201

199202
def accept_callback(self) -> bool:
200203
"""
201-
Accept the webhook if the pipeline job failed due to a script failure and there are related merge requests.
204+
Accept callback if the pipeline failed and has a failed build to fix.
202205
"""
203206
return (
204-
not self.build_allow_failure
205-
and self._repo_config.features.autofix_pipeline_enabled
206-
and self.build_status == "failed"
207-
# Only fix pipeline jobs that failed due to a script failure.
208-
and self.build_failure_reason == "script_failure"
209-
# Only fix pipeline jobs of the latest commit of the merge request.
210-
and self.merge_request is not None
211-
and self.merge_request.is_daiv()
212-
and self.merge_request.sha == self.sha
207+
self._repo_config.features.autofix_pipeline_enabled
208+
and self.object_attributes.status == "failed"
209+
and self._first_failed_build is not None
210+
and self._merge_request is not None
211+
and self._merge_request.is_daiv()
213212
)
214213

215214
async def process_callback(self):
216215
"""
217-
Trigger the task to fix the pipeline job.
216+
Trigger the task to fix the pipeline failed build.
217+
218+
Only one build is fixed at a time to avoid two or more fixes being applied simultaneously to the same files,
219+
which could lead to conflicts or a job being fixed with outdated code.
218220
"""
219-
if self.merge_request:
221+
if self.merge_request is not None and self._first_failed_build is not None:
220222
await sync_to_async(
221223
fix_pipeline_job_task.si(
222224
repo_id=self.project.path_with_namespace,
223225
ref=self.merge_request.source_branch,
224-
merge_request_id=self.merge_request.merge_request_id,
225-
job_id=self.build_id,
226-
job_name=self.build_name,
227-
thread_id=generate_uuid(
228-
f"{self.project.path_with_namespace}{self.merge_request.merge_request_id}{self.build_name}"
229-
),
226+
merge_request_id=self.merge_request.iid,
227+
job_id=self._first_failed_build.id,
228+
job_name=self._first_failed_build.name,
230229
).delay
231230
)()
232231

233232
@cached_property
234-
def merge_request(self) -> BaseMergeRequest | None:
233+
def _merge_request(self) -> BaseMergeRequest | None:
235234
"""
236-
Get the merge request related to the job.
235+
Get the merge request related to the pipeline to obtain associated labels and infer if is a DAIV MR.
237236
"""
238-
# The ref points to the source branch of a merge request.
239-
match = re.search(rf"{PIPELINE_JOB_REF_SUFFIX}(\d+)(?:/\w+)?$", self.ref)
240-
if match:
241-
client = RepoClient.create_instance()
242-
return client.get_merge_request(self.project.path_with_namespace, int(match.group(1)))
237+
client = RepoClient.create_instance()
238+
if self.merge_request is not None:
239+
return client.get_merge_request(self.project.path_with_namespace, self.merge_request.iid)
243240
return None
241+
242+
@cached_property
243+
def _first_failed_build(self) -> PipelineBuild | None:
244+
"""
245+
Get the first failed build of the pipeline.
246+
"""
247+
return next(
248+
(
249+
build
250+
for build in self.builds
251+
if build.status == "failed"
252+
and not build.manual
253+
and not build.allow_failure
254+
and build.failure_reason == "script_failure"
255+
),
256+
None,
257+
)

daiv/codebase/api/models.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from enum import StrEnum
22
from typing import Literal
33

4+
from ninja import Field
45
from pydantic import BaseModel
56

67
from core.constants import BOT_LABEL
@@ -53,13 +54,13 @@ class MergeRequest(BaseModel):
5354
id: int
5455
iid: int
5556
title: str
56-
description: str
57+
description: str | None = None
5758
state: str
58-
work_in_progress: bool
59+
work_in_progress: bool | None = None
5960
source_branch: str
6061
target_branch: str
61-
assignee_id: int | None
62-
labels: list[Label]
62+
assignee_id: int | None = None
63+
labels: list[Label] = Field(default_factory=list)
6364

6465
def is_daiv(self) -> bool:
6566
"""
@@ -164,3 +165,40 @@ class User(BaseModel):
164165
name: str
165166
username: str
166167
email: str
168+
169+
170+
class PipelineBuild(BaseModel):
171+
"""
172+
Gitlab Pipeline Build
173+
"""
174+
175+
id: int
176+
status: Literal["created", "pending", "running", "failed", "success", "canceled", "skipped", "manual", "scheduled"]
177+
name: str
178+
stage: str
179+
allow_failure: bool
180+
failure_reason: str | None = None
181+
manual: bool
182+
183+
184+
class Pipeline(BaseModel):
185+
"""
186+
Gitlab Pipeline
187+
"""
188+
189+
id: int
190+
ref: str
191+
sha: str
192+
status: Literal[
193+
"created",
194+
"waiting_for_resource",
195+
"preparing",
196+
"pending",
197+
"running",
198+
"failed",
199+
"success",
200+
"canceled",
201+
"skipped",
202+
"manual",
203+
"scheduled",
204+
]

daiv/codebase/api/views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
from ninja import Router
44

55
from .callbacks import UnprocessableEntityResponse
6-
from .callbacks_gitlab import IssueCallback, NoteCallback, PipelineJobCallback, PushCallback
6+
from .callbacks_gitlab import IssueCallback, NoteCallback, PipelineStatusCallback, PushCallback
77

88
logger = logging.getLogger("daiv.webhooks")
99

1010
router = Router()
1111

1212

1313
@router.post("/callbacks/gitlab/", response={204: None, 423: UnprocessableEntityResponse})
14-
async def gitlab_callback(request, payload: IssueCallback | NoteCallback | PushCallback | PipelineJobCallback):
14+
async def gitlab_callback(request, payload: IssueCallback | NoteCallback | PushCallback | PipelineStatusCallback):
1515
"""
1616
GitLab callback endpoint for processing callbacks.
1717
"""

0 commit comments

Comments
 (0)