logdetective 2.0.1__py3-none-any.whl → 2.11.0__py3-none-any.whl
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.
- logdetective/extractors.py +134 -23
- logdetective/logdetective.py +39 -23
- logdetective/models.py +26 -0
- logdetective/prompts-summary-first.yml +0 -2
- logdetective/prompts.yml +0 -3
- logdetective/server/compressors.py +7 -10
- logdetective/server/config.py +3 -2
- logdetective/server/database/base.py +31 -26
- logdetective/server/database/models/__init__.py +2 -2
- logdetective/server/database/models/exceptions.py +4 -0
- logdetective/server/database/models/koji.py +47 -30
- logdetective/server/database/models/merge_request_jobs.py +205 -186
- logdetective/server/database/models/metrics.py +87 -61
- logdetective/server/emoji.py +57 -55
- logdetective/server/exceptions.py +4 -0
- logdetective/server/gitlab.py +18 -11
- logdetective/server/llm.py +19 -10
- logdetective/server/metric.py +18 -13
- logdetective/server/models.py +65 -48
- logdetective/server/plot.py +13 -11
- logdetective/server/server.py +52 -30
- logdetective/server/templates/base_response.html.j2 +59 -0
- logdetective/server/templates/gitlab_full_comment.md.j2 +58 -53
- logdetective/server/templates/gitlab_short_comment.md.j2 +52 -47
- logdetective/server/utils.py +15 -27
- logdetective/utils.py +115 -49
- {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info}/METADATA +95 -21
- logdetective-2.11.0.dist-info/RECORD +40 -0
- {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info}/WHEEL +1 -1
- logdetective-2.0.1.dist-info/RECORD +0 -39
- {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info}/entry_points.txt +0 -0
- {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info/licenses}/LICENSE +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import
|
|
1
|
+
from __future__ import annotations
|
|
2
2
|
import enum
|
|
3
3
|
import datetime
|
|
4
|
-
from typing import Optional, List, Self, Tuple
|
|
4
|
+
from typing import Optional, List, Self, Tuple, TYPE_CHECKING
|
|
5
5
|
|
|
6
6
|
import backoff
|
|
7
7
|
|
|
8
8
|
from sqlalchemy import (
|
|
9
|
-
Column,
|
|
10
9
|
Integer,
|
|
11
10
|
Float,
|
|
12
11
|
DateTime,
|
|
@@ -17,7 +16,7 @@ from sqlalchemy import (
|
|
|
17
16
|
ForeignKey,
|
|
18
17
|
LargeBinary,
|
|
19
18
|
)
|
|
20
|
-
from sqlalchemy.orm import relationship, aliased
|
|
19
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship, aliased
|
|
21
20
|
from sqlalchemy.exc import OperationalError
|
|
22
21
|
|
|
23
22
|
from logdetective.server.database.base import Base, transaction, DB_MAX_RETRIES
|
|
@@ -27,6 +26,10 @@ from logdetective.server.database.models.merge_request_jobs import (
|
|
|
27
26
|
)
|
|
28
27
|
|
|
29
28
|
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from .koji import KojiTaskAnalysis
|
|
31
|
+
|
|
32
|
+
|
|
30
33
|
class EndpointType(enum.Enum):
|
|
31
34
|
"""Different analyze endpoints"""
|
|
32
35
|
|
|
@@ -42,43 +45,45 @@ class AnalyzeRequestMetrics(Base):
|
|
|
42
45
|
|
|
43
46
|
__tablename__ = "analyze_request_metrics"
|
|
44
47
|
|
|
45
|
-
id =
|
|
46
|
-
endpoint =
|
|
48
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
49
|
+
endpoint: Mapped[EndpointType] = mapped_column(
|
|
47
50
|
Enum(EndpointType),
|
|
48
51
|
nullable=False,
|
|
49
52
|
index=True,
|
|
50
53
|
comment="The service endpoint that was called",
|
|
51
54
|
)
|
|
52
|
-
request_received_at =
|
|
53
|
-
DateTime,
|
|
55
|
+
request_received_at: Mapped[datetime.datetime] = mapped_column(
|
|
56
|
+
DateTime(timezone=True),
|
|
54
57
|
nullable=False,
|
|
55
58
|
index=True,
|
|
56
59
|
default=datetime.datetime.now(datetime.timezone.utc),
|
|
57
60
|
comment="Timestamp when the request was received",
|
|
58
61
|
)
|
|
59
|
-
compressed_log =
|
|
62
|
+
compressed_log: Mapped[bytes] = mapped_column(
|
|
60
63
|
LargeBinary(length=314572800), # 300MB limit (300 * 1024 * 1024)
|
|
61
64
|
nullable=False,
|
|
62
65
|
index=False,
|
|
63
66
|
comment="Log processed, saved in a zip format",
|
|
64
67
|
)
|
|
65
|
-
compressed_response =
|
|
68
|
+
compressed_response: Mapped[Optional[bytes]] = mapped_column(
|
|
66
69
|
LargeBinary(length=314572800), # 300MB limit (300 * 1024 * 1024)
|
|
67
70
|
nullable=True,
|
|
68
71
|
index=False,
|
|
69
72
|
comment="Given response (with explanation and snippets) saved in a zip format",
|
|
70
73
|
)
|
|
71
|
-
response_sent_at =
|
|
72
|
-
DateTime
|
|
74
|
+
response_sent_at: Mapped[Optional[datetime.datetime]] = mapped_column(
|
|
75
|
+
DateTime(timezone=True),
|
|
76
|
+
nullable=True,
|
|
77
|
+
comment="Timestamp when the response was sent back",
|
|
73
78
|
)
|
|
74
|
-
response_length =
|
|
79
|
+
response_length: Mapped[Optional[int]] = mapped_column(
|
|
75
80
|
Integer, nullable=True, comment="Length of the response in chars"
|
|
76
81
|
)
|
|
77
|
-
response_certainty =
|
|
82
|
+
response_certainty: Mapped[Optional[float]] = mapped_column(
|
|
78
83
|
Float, nullable=True, comment="Certainty for generated response"
|
|
79
84
|
)
|
|
80
85
|
|
|
81
|
-
merge_request_job_id =
|
|
86
|
+
merge_request_job_id: Mapped[Optional[int]] = mapped_column(
|
|
82
87
|
Integer,
|
|
83
88
|
ForeignKey("gitlab_merge_request_jobs.id"),
|
|
84
89
|
nullable=True,
|
|
@@ -86,19 +91,27 @@ class AnalyzeRequestMetrics(Base):
|
|
|
86
91
|
comment="Is this an analyze request coming from a merge request?",
|
|
87
92
|
)
|
|
88
93
|
|
|
89
|
-
mr_job
|
|
94
|
+
mr_job: Mapped[Optional["GitlabMergeRequestJobs"]] = relationship(
|
|
95
|
+
"GitlabMergeRequestJobs",
|
|
96
|
+
back_populates="request_metrics"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
koji_tasks: Mapped[List["KojiTaskAnalysis"]] = relationship(
|
|
100
|
+
"KojiTaskAnalysis",
|
|
101
|
+
back_populates="response"
|
|
102
|
+
)
|
|
90
103
|
|
|
91
104
|
@classmethod
|
|
92
105
|
@backoff.on_exception(backoff.expo, OperationalError, max_tries=DB_MAX_RETRIES)
|
|
93
|
-
def create(
|
|
106
|
+
async def create(
|
|
94
107
|
cls,
|
|
95
108
|
endpoint: EndpointType,
|
|
96
|
-
compressed_log:
|
|
109
|
+
compressed_log: bytes,
|
|
97
110
|
request_received_at: Optional[datetime.datetime] = None,
|
|
98
111
|
) -> int:
|
|
99
112
|
"""Create AnalyzeRequestMetrics new line
|
|
100
113
|
with data related to a received request"""
|
|
101
|
-
with transaction(commit=True) as session:
|
|
114
|
+
async with transaction(commit=True) as session:
|
|
102
115
|
metrics = AnalyzeRequestMetrics()
|
|
103
116
|
metrics.endpoint = endpoint
|
|
104
117
|
metrics.compressed_log = compressed_log
|
|
@@ -106,12 +119,12 @@ class AnalyzeRequestMetrics(Base):
|
|
|
106
119
|
datetime.timezone.utc
|
|
107
120
|
)
|
|
108
121
|
session.add(metrics)
|
|
109
|
-
session.flush()
|
|
122
|
+
await session.flush()
|
|
110
123
|
return metrics.id
|
|
111
124
|
|
|
112
125
|
@classmethod
|
|
113
126
|
@backoff.on_exception(backoff.expo, OperationalError, max_tries=DB_MAX_RETRIES)
|
|
114
|
-
def update( # pylint: disable=too-many-arguments disable=too-many-positional-arguments
|
|
127
|
+
async def update( # pylint: disable=too-many-arguments disable=too-many-positional-arguments
|
|
115
128
|
cls,
|
|
116
129
|
id_: int,
|
|
117
130
|
response_sent_at: DateTime,
|
|
@@ -121,8 +134,10 @@ class AnalyzeRequestMetrics(Base):
|
|
|
121
134
|
) -> None:
|
|
122
135
|
"""Update a row
|
|
123
136
|
with data related to the given response"""
|
|
124
|
-
|
|
125
|
-
|
|
137
|
+
query = select(AnalyzeRequestMetrics).filter(AnalyzeRequestMetrics.id == id_)
|
|
138
|
+
async with transaction(commit=True) as session:
|
|
139
|
+
query_result = await session.execute(query)
|
|
140
|
+
metrics = query_result.scalars().first()
|
|
126
141
|
if metrics is None:
|
|
127
142
|
raise ValueError("Returned `AnalyzeRequestMetrics` table is empty.")
|
|
128
143
|
metrics.response_sent_at = response_sent_at
|
|
@@ -133,19 +148,21 @@ class AnalyzeRequestMetrics(Base):
|
|
|
133
148
|
|
|
134
149
|
@classmethod
|
|
135
150
|
@backoff.on_exception(backoff.expo, OperationalError, max_tries=DB_MAX_RETRIES)
|
|
136
|
-
def get_metric_by_id(
|
|
151
|
+
async def get_metric_by_id(
|
|
137
152
|
cls,
|
|
138
153
|
id_: int,
|
|
139
154
|
) -> Self:
|
|
140
155
|
"""Update a row
|
|
141
156
|
with data related to the given response"""
|
|
142
|
-
|
|
143
|
-
|
|
157
|
+
query = select(AnalyzeRequestMetrics).filter(AnalyzeRequestMetrics.id == id_)
|
|
158
|
+
async with transaction(commit=True) as session:
|
|
159
|
+
query_result = await session.execute(query)
|
|
160
|
+
metric = query_result.scalars().first()
|
|
144
161
|
if metric is None:
|
|
145
162
|
raise ValueError("Returned `AnalyzeRequestMetrics` table is empty.")
|
|
146
163
|
return metric
|
|
147
164
|
|
|
148
|
-
def add_mr_job(
|
|
165
|
+
async def add_mr_job(
|
|
149
166
|
self,
|
|
150
167
|
forge: Forge,
|
|
151
168
|
project_id: int,
|
|
@@ -161,13 +178,15 @@ class AnalyzeRequestMetrics(Base):
|
|
|
161
178
|
mr_iid: merge request forge iid
|
|
162
179
|
job_id: forge job id
|
|
163
180
|
"""
|
|
164
|
-
mr_job = GitlabMergeRequestJobs.get_or_create(
|
|
181
|
+
mr_job = await GitlabMergeRequestJobs.get_or_create(
|
|
182
|
+
forge, project_id, mr_iid, job_id
|
|
183
|
+
)
|
|
165
184
|
self.merge_request_job_id = mr_job.id
|
|
166
|
-
with transaction(commit=True) as session:
|
|
167
|
-
session.merge(self)
|
|
185
|
+
async with transaction(commit=True) as session:
|
|
186
|
+
await session.merge(self)
|
|
168
187
|
|
|
169
188
|
@classmethod
|
|
170
|
-
def get_requests_metrics_for_mr_job(
|
|
189
|
+
async def get_requests_metrics_for_mr_job(
|
|
171
190
|
cls,
|
|
172
191
|
forge: Forge,
|
|
173
192
|
project_id: int,
|
|
@@ -182,19 +201,20 @@ class AnalyzeRequestMetrics(Base):
|
|
|
182
201
|
mr_iid: merge request forge iid
|
|
183
202
|
job_id: forge job id
|
|
184
203
|
"""
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
mr_job_alias.job_id == job_id,
|
|
195
|
-
)
|
|
196
|
-
.all()
|
|
204
|
+
mr_job_alias = aliased(GitlabMergeRequestJobs)
|
|
205
|
+
query = (
|
|
206
|
+
select(cls)
|
|
207
|
+
.join(mr_job_alias, cls.merge_request_job_id == mr_job_alias.id)
|
|
208
|
+
.filter(
|
|
209
|
+
mr_job_alias.forge == forge,
|
|
210
|
+
mr_job_alias.mr_iid == mr_iid,
|
|
211
|
+
mr_job_alias.project_id == project_id,
|
|
212
|
+
mr_job_alias.job_id == job_id,
|
|
197
213
|
)
|
|
214
|
+
)
|
|
215
|
+
async with transaction(commit=False) as session:
|
|
216
|
+
query_result = await session.execute(query)
|
|
217
|
+
metrics = query_result.scalars().all()
|
|
198
218
|
return metrics
|
|
199
219
|
|
|
200
220
|
@classmethod
|
|
@@ -242,7 +262,7 @@ class AnalyzeRequestMetrics(Base):
|
|
|
242
262
|
return requests_by_time_format
|
|
243
263
|
|
|
244
264
|
@classmethod
|
|
245
|
-
def get_requests_in_period(
|
|
265
|
+
async def get_requests_in_period(
|
|
246
266
|
cls,
|
|
247
267
|
start_time: datetime.datetime,
|
|
248
268
|
end_time: datetime.datetime,
|
|
@@ -261,7 +281,7 @@ class AnalyzeRequestMetrics(Base):
|
|
|
261
281
|
Returns:
|
|
262
282
|
dict[datetime, int]: A dictionary mapping datetime objects to request counts
|
|
263
283
|
"""
|
|
264
|
-
with transaction(commit=False) as session:
|
|
284
|
+
async with transaction(commit=False) as session:
|
|
265
285
|
requests_by_time_format = cls._get_requests_by_time_for_postgres(
|
|
266
286
|
start_time, end_time, time_format, endpoint
|
|
267
287
|
)
|
|
@@ -271,13 +291,13 @@ class AnalyzeRequestMetrics(Base):
|
|
|
271
291
|
func.count(distinct(requests_by_time_format.c.id)), # pylint: disable=not-callable
|
|
272
292
|
).group_by("time_format")
|
|
273
293
|
|
|
274
|
-
|
|
275
|
-
results =
|
|
294
|
+
query_results = await session.execute(count_requests_by_time_format)
|
|
295
|
+
results = query_results.all()
|
|
276
296
|
|
|
277
297
|
return cls.get_dictionary_with_datetime_keys(time_format, results)
|
|
278
298
|
|
|
279
299
|
@classmethod
|
|
280
|
-
def _get_average_responses_times_for_postgres(
|
|
300
|
+
async def _get_average_responses_times_for_postgres(
|
|
281
301
|
cls, start_time, end_time, time_format, endpoint
|
|
282
302
|
):
|
|
283
303
|
"""Get average responses time.
|
|
@@ -285,7 +305,7 @@ class AnalyzeRequestMetrics(Base):
|
|
|
285
305
|
func.to_char is PostgreSQL specific.
|
|
286
306
|
Let's unit tests replace this function with the SQLite version.
|
|
287
307
|
"""
|
|
288
|
-
with transaction(commit=False) as session:
|
|
308
|
+
async with transaction(commit=False) as session:
|
|
289
309
|
pgsql_time_format = cls.get_postgres_time_format(time_format)
|
|
290
310
|
|
|
291
311
|
average_responses_times = (
|
|
@@ -307,11 +327,12 @@ class AnalyzeRequestMetrics(Base):
|
|
|
307
327
|
.order_by("time_range")
|
|
308
328
|
)
|
|
309
329
|
|
|
310
|
-
|
|
330
|
+
query_results = await session.execute(average_responses_times)
|
|
331
|
+
results = query_results.all()
|
|
311
332
|
return results
|
|
312
333
|
|
|
313
334
|
@classmethod
|
|
314
|
-
def get_responses_average_time_in_period(
|
|
335
|
+
async def get_responses_average_time_in_period(
|
|
315
336
|
cls,
|
|
316
337
|
start_time: datetime.datetime,
|
|
317
338
|
end_time: datetime.datetime,
|
|
@@ -332,9 +353,11 @@ class AnalyzeRequestMetrics(Base):
|
|
|
332
353
|
dict[datetime, int]: A dictionary mapping datetime objects
|
|
333
354
|
to average responses times
|
|
334
355
|
"""
|
|
335
|
-
with transaction(commit=False) as _:
|
|
336
|
-
average_responses_times =
|
|
337
|
-
|
|
356
|
+
async with transaction(commit=False) as _:
|
|
357
|
+
average_responses_times = (
|
|
358
|
+
await cls._get_average_responses_times_for_postgres(
|
|
359
|
+
start_time, end_time, time_format, endpoint
|
|
360
|
+
)
|
|
338
361
|
)
|
|
339
362
|
|
|
340
363
|
return cls.get_dictionary_with_datetime_keys(
|
|
@@ -342,7 +365,7 @@ class AnalyzeRequestMetrics(Base):
|
|
|
342
365
|
)
|
|
343
366
|
|
|
344
367
|
@classmethod
|
|
345
|
-
def _get_average_responses_lengths_for_postgres(
|
|
368
|
+
async def _get_average_responses_lengths_for_postgres(
|
|
346
369
|
cls, start_time, end_time, time_format, endpoint
|
|
347
370
|
):
|
|
348
371
|
"""Get average responses length.
|
|
@@ -350,7 +373,7 @@ class AnalyzeRequestMetrics(Base):
|
|
|
350
373
|
func.to_char is PostgreSQL specific.
|
|
351
374
|
Let's unit tests replace this function with the SQLite version.
|
|
352
375
|
"""
|
|
353
|
-
with transaction(commit=False) as session:
|
|
376
|
+
async with transaction(commit=False) as session:
|
|
354
377
|
pgsql_time_format = cls.get_postgres_time_format(time_format)
|
|
355
378
|
|
|
356
379
|
average_responses_lengths = (
|
|
@@ -366,11 +389,12 @@ class AnalyzeRequestMetrics(Base):
|
|
|
366
389
|
.order_by("time_range")
|
|
367
390
|
)
|
|
368
391
|
|
|
369
|
-
|
|
392
|
+
query_results = await session.execute(average_responses_lengths)
|
|
393
|
+
results = query_results.all()
|
|
370
394
|
return results
|
|
371
395
|
|
|
372
396
|
@classmethod
|
|
373
|
-
def get_responses_average_length_in_period(
|
|
397
|
+
async def get_responses_average_length_in_period(
|
|
374
398
|
cls,
|
|
375
399
|
start_time: datetime.datetime,
|
|
376
400
|
end_time: datetime.datetime,
|
|
@@ -391,9 +415,11 @@ class AnalyzeRequestMetrics(Base):
|
|
|
391
415
|
dict[datetime, int]: A dictionary mapping datetime objects
|
|
392
416
|
to average responses lengths
|
|
393
417
|
"""
|
|
394
|
-
with transaction(commit=False) as _:
|
|
395
|
-
average_responses_lengths =
|
|
396
|
-
|
|
418
|
+
async with transaction(commit=False) as _:
|
|
419
|
+
average_responses_lengths = (
|
|
420
|
+
await cls._get_average_responses_lengths_for_postgres(
|
|
421
|
+
start_time, end_time, time_format, endpoint
|
|
422
|
+
)
|
|
397
423
|
)
|
|
398
424
|
|
|
399
425
|
return cls.get_dictionary_with_datetime_keys(
|
logdetective/server/emoji.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
|
|
3
|
-
from typing import List
|
|
3
|
+
from typing import List
|
|
4
4
|
from collections import Counter
|
|
5
5
|
|
|
6
6
|
import gitlab
|
|
@@ -20,7 +20,7 @@ async def collect_emojis(gitlab_conn: gitlab.Gitlab, period: TimePeriod):
|
|
|
20
20
|
Collect emoji feedback from logdetective comments saved in database.
|
|
21
21
|
Check only comments created in the last given period of time.
|
|
22
22
|
"""
|
|
23
|
-
comments = Comments.get_since(period.get_period_start_time()) or []
|
|
23
|
+
comments = await Comments.get_since(period.get_period_start_time()) or []
|
|
24
24
|
comments_for_gitlab_connection = [
|
|
25
25
|
comment for comment in comments if comment.forge == gitlab_conn.url
|
|
26
26
|
]
|
|
@@ -39,29 +39,14 @@ async def collect_emojis_for_mr(
|
|
|
39
39
|
except ValueError as ex:
|
|
40
40
|
LOG.exception("Attempt to use unrecognized Forge `%s`", gitlab_conn.url)
|
|
41
41
|
raise ex
|
|
42
|
-
mr_jobs = GitlabMergeRequestJobs.get_by_mr_iid(url, project_id, mr_iid) or []
|
|
43
|
-
|
|
44
|
-
comments = [Comments.get_by_mr_job(mr_job) for mr_job in mr_jobs]
|
|
45
|
-
await collect_emojis_in_comments(comments, gitlab_conn)
|
|
42
|
+
mr_jobs = await GitlabMergeRequestJobs.get_by_mr_iid(url, project_id, mr_iid) or []
|
|
46
43
|
|
|
44
|
+
comments = [await Comments.get_by_mr_job(mr_job) for mr_job in mr_jobs]
|
|
45
|
+
# Filter all cases when no comments were found. This shouldn't happen if the database
|
|
46
|
+
# is in good order. But checking for it can't hurt.
|
|
47
|
+
comments = [comment for comment in comments if isinstance(comment, Comments)]
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
"""
|
|
50
|
-
It handles errors for the specified GitLab operation.
|
|
51
|
-
After executing it in a separate thread.
|
|
52
|
-
"""
|
|
53
|
-
try:
|
|
54
|
-
return await asyncio.to_thread(func, *args)
|
|
55
|
-
except (gitlab.GitlabError, gitlab.GitlabGetError) as e:
|
|
56
|
-
log_msg = f"Error during GitLab operation {func}{args}: {e}"
|
|
57
|
-
if "Not Found" in str(e):
|
|
58
|
-
LOG.error(log_msg)
|
|
59
|
-
else:
|
|
60
|
-
LOG.exception(log_msg)
|
|
61
|
-
except Exception as e: # pylint: disable=broad-exception-caught
|
|
62
|
-
LOG.exception(
|
|
63
|
-
"Unexpected error during GitLab operation %s(%s): %s", func, args, e
|
|
64
|
-
)
|
|
49
|
+
await collect_emojis_in_comments(comments, gitlab_conn)
|
|
65
50
|
|
|
66
51
|
|
|
67
52
|
async def collect_emojis_in_comments( # pylint: disable=too-many-locals
|
|
@@ -73,40 +58,57 @@ async def collect_emojis_in_comments( # pylint: disable=too-many-locals
|
|
|
73
58
|
projects = {}
|
|
74
59
|
merge_requests = {}
|
|
75
60
|
for comment in comments:
|
|
76
|
-
mr_job_db = GitlabMergeRequestJobs.get_by_id(comment.merge_request_job_id)
|
|
61
|
+
mr_job_db = await GitlabMergeRequestJobs.get_by_id(comment.merge_request_job_id)
|
|
77
62
|
if not mr_job_db:
|
|
78
63
|
continue
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
64
|
+
try:
|
|
65
|
+
if mr_job_db.id not in projects:
|
|
66
|
+
project = await asyncio.to_thread(
|
|
67
|
+
gitlab_conn.projects.get, mr_job_db.project_id
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
projects[mr_job_db.id] = project
|
|
71
|
+
else:
|
|
72
|
+
project = projects[mr_job_db.id]
|
|
73
|
+
merge_request_iid = mr_job_db.mr_iid
|
|
74
|
+
if merge_request_iid not in merge_requests:
|
|
75
|
+
merge_request = await asyncio.to_thread(
|
|
76
|
+
project.mergerequests.get, merge_request_iid
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
merge_requests[merge_request_iid] = merge_request
|
|
80
|
+
else:
|
|
81
|
+
merge_request = merge_requests[merge_request_iid]
|
|
82
|
+
|
|
83
|
+
discussion = await asyncio.to_thread(
|
|
84
|
+
merge_request.discussions.get, comment.comment_id
|
|
82
85
|
)
|
|
83
|
-
if not project:
|
|
84
|
-
continue
|
|
85
|
-
projects[mr_job_db.id] = project
|
|
86
|
-
else:
|
|
87
|
-
project = projects[mr_job_db.id]
|
|
88
|
-
merge_request_iid = mr_job_db.mr_iid
|
|
89
|
-
if merge_request_iid not in merge_requests:
|
|
90
|
-
merge_request = await _handle_gitlab_operation(
|
|
91
|
-
project.mergerequests.get, merge_request_iid
|
|
92
|
-
)
|
|
93
|
-
if not merge_request:
|
|
94
|
-
continue
|
|
95
|
-
merge_requests[merge_request_iid] = merge_request
|
|
96
|
-
else:
|
|
97
|
-
merge_request = merge_requests[merge_request_iid]
|
|
98
86
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
87
|
+
# Get the ID of the first note
|
|
88
|
+
if "notes" not in discussion.attributes or len(discussion.attributes["notes"]) == 0:
|
|
89
|
+
LOG.warning(
|
|
90
|
+
"No notes were found in comment %s in merge request %d",
|
|
91
|
+
comment.comment_id,
|
|
92
|
+
merge_request_iid,
|
|
93
|
+
)
|
|
94
|
+
continue
|
|
104
95
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
96
|
+
note_id = discussion.attributes["notes"][0]["id"]
|
|
97
|
+
note = await asyncio.to_thread(merge_request.notes.get, note_id)
|
|
98
|
+
|
|
99
|
+
# Log warning with full stack trace, in case we can't find the right
|
|
100
|
+
# discussion, merge request or project.
|
|
101
|
+
# All of these objects can be lost, and we shouldn't treat as an error.
|
|
102
|
+
# Other exceptions are raised.
|
|
103
|
+
except gitlab.GitlabError as e:
|
|
104
|
+
if e.response_code == 404:
|
|
105
|
+
LOG.warning(
|
|
106
|
+
"Couldn't retrieve emoji counts for comment %s due to GitlabError",
|
|
107
|
+
comment.comment_id, exc_info=True)
|
|
108
|
+
continue
|
|
109
|
+
LOG.error("Error encountered while processing emoji counts for GitLab comment %s",
|
|
110
|
+
comment.comment_id, exc_info=True)
|
|
111
|
+
raise
|
|
110
112
|
|
|
111
113
|
emoji_counts = Counter(emoji.name for emoji in note.awardemojis.list())
|
|
112
114
|
|
|
@@ -114,7 +116,7 @@ async def collect_emojis_in_comments( # pylint: disable=too-many-locals
|
|
|
114
116
|
# because we need to remove them
|
|
115
117
|
old_emojis = [
|
|
116
118
|
reaction.reaction_type
|
|
117
|
-
for reaction in Reactions.get_all_reactions(
|
|
119
|
+
for reaction in await Reactions.get_all_reactions(
|
|
118
120
|
comment.forge,
|
|
119
121
|
mr_job_db.project_id,
|
|
120
122
|
mr_job_db.mr_iid,
|
|
@@ -123,7 +125,7 @@ async def collect_emojis_in_comments( # pylint: disable=too-many-locals
|
|
|
123
125
|
)
|
|
124
126
|
]
|
|
125
127
|
for key, value in emoji_counts.items():
|
|
126
|
-
Reactions.create_or_update(
|
|
128
|
+
await Reactions.create_or_update(
|
|
127
129
|
comment.forge,
|
|
128
130
|
mr_job_db.project_id,
|
|
129
131
|
mr_job_db.mr_iid,
|
|
@@ -136,7 +138,7 @@ async def collect_emojis_in_comments( # pylint: disable=too-many-locals
|
|
|
136
138
|
old_emojis.remove(key)
|
|
137
139
|
|
|
138
140
|
# not updated reactions has been removed, drop them
|
|
139
|
-
Reactions.delete(
|
|
141
|
+
await Reactions.delete(
|
|
140
142
|
comment.forge,
|
|
141
143
|
mr_job_db.project_id,
|
|
142
144
|
mr_job_db.mr_iid,
|
|
@@ -31,3 +31,7 @@ class LogDetectiveConnectionError(LogDetectiveKojiException):
|
|
|
31
31
|
|
|
32
32
|
class LogsTooLargeError(LogDetectiveKojiException):
|
|
33
33
|
"""The log archive exceeds the configured maximum size"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LogDetectiveMetricsError(LogDetectiveException):
|
|
37
|
+
"""Exception was encountered while recording metrics"""
|
logdetective/server/gitlab.py
CHANGED
|
@@ -11,9 +11,13 @@ import gitlab.v4
|
|
|
11
11
|
import gitlab.v4.objects
|
|
12
12
|
import jinja2
|
|
13
13
|
import aiohttp
|
|
14
|
+
import backoff
|
|
14
15
|
|
|
15
16
|
from logdetective.server.config import SERVER_CONFIG, LOG
|
|
16
|
-
from logdetective.server.exceptions import
|
|
17
|
+
from logdetective.server.exceptions import (
|
|
18
|
+
LogsTooLargeError,
|
|
19
|
+
LogDetectiveConnectionError,
|
|
20
|
+
)
|
|
17
21
|
from logdetective.server.llm import perform_staged_analysis
|
|
18
22
|
from logdetective.server.metric import add_new_metrics, update_metrics
|
|
19
23
|
from logdetective.server.models import (
|
|
@@ -29,6 +33,7 @@ from logdetective.server.database.models import (
|
|
|
29
33
|
GitlabMergeRequestJobs,
|
|
30
34
|
)
|
|
31
35
|
from logdetective.server.compressors import RemoteLogCompressor
|
|
36
|
+
from logdetective.server.utils import connection_error_giveup
|
|
32
37
|
|
|
33
38
|
MR_REGEX = re.compile(r"refs/merge-requests/(\d+)/.*$")
|
|
34
39
|
FAILURE_LOG_REGEX = re.compile(r"(\w*\.log)")
|
|
@@ -75,7 +80,7 @@ async def process_gitlab_job_event(
|
|
|
75
80
|
# Check if this is a resubmission of an existing, completed job.
|
|
76
81
|
# If it is, we'll exit out here and not waste time retrieving the logs,
|
|
77
82
|
# running a new analysis or trying to submit a new comment.
|
|
78
|
-
mr_job_db = GitlabMergeRequestJobs.get_by_details(
|
|
83
|
+
mr_job_db = await GitlabMergeRequestJobs.get_by_details(
|
|
79
84
|
forge=forge,
|
|
80
85
|
project_id=project.id,
|
|
81
86
|
mr_iid=merge_request_iid,
|
|
@@ -91,8 +96,8 @@ async def process_gitlab_job_event(
|
|
|
91
96
|
log_url, preprocessed_log = await retrieve_and_preprocess_koji_logs(
|
|
92
97
|
gitlab_cfg, job
|
|
93
98
|
)
|
|
94
|
-
except LogsTooLargeError:
|
|
95
|
-
LOG.error("Could not retrieve logs
|
|
99
|
+
except (LogsTooLargeError, LogDetectiveConnectionError) as ex:
|
|
100
|
+
LOG.error("Could not retrieve logs due to %s", ex)
|
|
96
101
|
raise
|
|
97
102
|
|
|
98
103
|
# Submit log to Log Detective and await the results.
|
|
@@ -104,7 +109,7 @@ async def process_gitlab_job_event(
|
|
|
104
109
|
compressed_log_content=RemoteLogCompressor.zip_text(log_text),
|
|
105
110
|
)
|
|
106
111
|
staged_response = await perform_staged_analysis(log_text=log_text)
|
|
107
|
-
update_metrics(metrics_id, staged_response)
|
|
112
|
+
await update_metrics(metrics_id, staged_response)
|
|
108
113
|
preprocessed_log.close()
|
|
109
114
|
|
|
110
115
|
# check if this project is on the opt-in list for posting comments.
|
|
@@ -151,6 +156,9 @@ def is_eligible_package(project_name: str):
|
|
|
151
156
|
return True
|
|
152
157
|
|
|
153
158
|
|
|
159
|
+
@backoff.on_exception(
|
|
160
|
+
backoff.expo, ConnectionResetError, max_time=60, on_giveup=connection_error_giveup
|
|
161
|
+
)
|
|
154
162
|
async def retrieve_and_preprocess_koji_logs(
|
|
155
163
|
gitlab_cfg: GitLabInstanceConfig,
|
|
156
164
|
job: gitlab.v4.objects.ProjectJob,
|
|
@@ -256,8 +264,7 @@ async def retrieve_and_preprocess_koji_logs(
|
|
|
256
264
|
LOG.debug("Failed architecture: %s", failed_arch)
|
|
257
265
|
|
|
258
266
|
log_path = failed_arches[failed_arch].as_posix()
|
|
259
|
-
|
|
260
|
-
log_url = f"{gitlab_cfg.api_path}/projects/{job.project_id}/jobs/{job.id}/artifacts/{log_path}" # pylint: disable=line-too-long
|
|
267
|
+
log_url = f"{gitlab_cfg.url}/{gitlab_cfg.api_path}/projects/{job.project_id}/jobs/{job.id}/artifacts/{log_path}" # pylint: disable=line-too-long
|
|
261
268
|
LOG.debug("Returning contents of %s%s", gitlab_cfg.url, log_url)
|
|
262
269
|
|
|
263
270
|
# Return the log as a file-like object with .read() function
|
|
@@ -350,13 +357,13 @@ async def comment_on_mr( # pylint: disable=too-many-arguments disable=too-many-
|
|
|
350
357
|
await asyncio.to_thread(note.save)
|
|
351
358
|
|
|
352
359
|
# Save the new comment to the database
|
|
353
|
-
metrics = AnalyzeRequestMetrics.get_metric_by_id(metrics_id)
|
|
354
|
-
Comments.create(
|
|
360
|
+
metrics = await AnalyzeRequestMetrics.get_metric_by_id(metrics_id)
|
|
361
|
+
await Comments.create(
|
|
355
362
|
forge,
|
|
356
363
|
project.id,
|
|
357
364
|
merge_request_iid,
|
|
358
365
|
job.id,
|
|
359
|
-
discussion.id,
|
|
366
|
+
str(discussion.id),
|
|
360
367
|
metrics,
|
|
361
368
|
)
|
|
362
369
|
|
|
@@ -371,7 +378,7 @@ async def suppress_latest_comment(
|
|
|
371
378
|
superseded by a new push."""
|
|
372
379
|
|
|
373
380
|
# Ask the database for the last known comment for this MR
|
|
374
|
-
previous_comment = Comments.get_latest_comment(
|
|
381
|
+
previous_comment = await Comments.get_latest_comment(
|
|
375
382
|
gitlab_instance, project.id, merge_request_iid
|
|
376
383
|
)
|
|
377
384
|
|