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.
Files changed (32) hide show
  1. logdetective/extractors.py +134 -23
  2. logdetective/logdetective.py +39 -23
  3. logdetective/models.py +26 -0
  4. logdetective/prompts-summary-first.yml +0 -2
  5. logdetective/prompts.yml +0 -3
  6. logdetective/server/compressors.py +7 -10
  7. logdetective/server/config.py +3 -2
  8. logdetective/server/database/base.py +31 -26
  9. logdetective/server/database/models/__init__.py +2 -2
  10. logdetective/server/database/models/exceptions.py +4 -0
  11. logdetective/server/database/models/koji.py +47 -30
  12. logdetective/server/database/models/merge_request_jobs.py +205 -186
  13. logdetective/server/database/models/metrics.py +87 -61
  14. logdetective/server/emoji.py +57 -55
  15. logdetective/server/exceptions.py +4 -0
  16. logdetective/server/gitlab.py +18 -11
  17. logdetective/server/llm.py +19 -10
  18. logdetective/server/metric.py +18 -13
  19. logdetective/server/models.py +65 -48
  20. logdetective/server/plot.py +13 -11
  21. logdetective/server/server.py +52 -30
  22. logdetective/server/templates/base_response.html.j2 +59 -0
  23. logdetective/server/templates/gitlab_full_comment.md.j2 +58 -53
  24. logdetective/server/templates/gitlab_short_comment.md.j2 +52 -47
  25. logdetective/server/utils.py +15 -27
  26. logdetective/utils.py +115 -49
  27. {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info}/METADATA +95 -21
  28. logdetective-2.11.0.dist-info/RECORD +40 -0
  29. {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info}/WHEEL +1 -1
  30. logdetective-2.0.1.dist-info/RECORD +0 -39
  31. {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info}/entry_points.txt +0 -0
  32. {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info/licenses}/LICENSE +0 -0
@@ -1,12 +1,11 @@
1
- import io
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 = Column(Integer, primary_key=True)
46
- endpoint = Column(
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 = Column(
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 = Column(
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 = Column(
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 = Column(
72
- DateTime, nullable=True, comment="Timestamp when the response was sent back"
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 = Column(
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 = Column(
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 = Column(
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 = relationship("GitlabMergeRequestJobs", back_populates="request_metrics")
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: io.BytesIO,
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
- with transaction(commit=True) as session:
125
- metrics = session.query(AnalyzeRequestMetrics).filter_by(id=id_).first()
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
- with transaction(commit=True) as session:
143
- metric = session.query(AnalyzeRequestMetrics).filter_by(id=id_).first()
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(forge, project_id, mr_iid, job_id)
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
- with transaction(commit=False) as session:
186
- mr_job_alias = aliased(GitlabMergeRequestJobs)
187
- metrics = (
188
- session.query(cls)
189
- .join(mr_job_alias, cls.merge_request_job_id == mr_job_alias.id)
190
- .filter(
191
- mr_job_alias.forge == forge,
192
- mr_job_alias.mr_iid == mr_iid,
193
- mr_job_alias.project_id == project_id,
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
- counts = session.execute(count_requests_by_time_format)
275
- results = counts.fetchall()
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
- results = session.execute(average_responses_times).fetchall()
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 = cls._get_average_responses_times_for_postgres(
337
- start_time, end_time, time_format, endpoint
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
- results = session.execute(average_responses_lengths).fetchall()
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 = cls._get_average_responses_lengths_for_postgres(
396
- start_time, end_time, time_format, endpoint
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(
@@ -1,6 +1,6 @@
1
1
  import asyncio
2
2
 
3
- from typing import List, Callable
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
- async def _handle_gitlab_operation(func: Callable, *args):
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
- if mr_job_db.id not in projects:
80
- project = await _handle_gitlab_operation(
81
- gitlab_conn.projects.get, mr_job_db.project_id
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
- discussion = await _handle_gitlab_operation(
100
- merge_request.discussions.get, comment.comment_id
101
- )
102
- if not discussion:
103
- continue
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
- # Get the ID of the first note
106
- note_id = discussion.attributes["notes"][0]["id"]
107
- note = await _handle_gitlab_operation(merge_request.notes.get, note_id)
108
- if not note:
109
- continue
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"""
@@ -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 LogsTooLargeError
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. Too large.")
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