logdetective 0.4.0__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 (39) hide show
  1. logdetective/constants.py +33 -12
  2. logdetective/extractors.py +137 -68
  3. logdetective/logdetective.py +102 -33
  4. logdetective/models.py +99 -0
  5. logdetective/prompts-summary-first.yml +20 -0
  6. logdetective/prompts-summary-only.yml +13 -0
  7. logdetective/prompts.yml +90 -0
  8. logdetective/remote_log.py +67 -0
  9. logdetective/server/compressors.py +186 -0
  10. logdetective/server/config.py +78 -0
  11. logdetective/server/database/base.py +34 -26
  12. logdetective/server/database/models/__init__.py +33 -0
  13. logdetective/server/database/models/exceptions.py +17 -0
  14. logdetective/server/database/models/koji.py +143 -0
  15. logdetective/server/database/models/merge_request_jobs.py +623 -0
  16. logdetective/server/database/models/metrics.py +427 -0
  17. logdetective/server/emoji.py +148 -0
  18. logdetective/server/exceptions.py +37 -0
  19. logdetective/server/gitlab.py +451 -0
  20. logdetective/server/koji.py +159 -0
  21. logdetective/server/llm.py +309 -0
  22. logdetective/server/metric.py +75 -30
  23. logdetective/server/models.py +426 -23
  24. logdetective/server/plot.py +432 -0
  25. logdetective/server/server.py +580 -468
  26. logdetective/server/templates/base_response.html.j2 +59 -0
  27. logdetective/server/templates/gitlab_full_comment.md.j2 +73 -0
  28. logdetective/server/templates/gitlab_short_comment.md.j2 +62 -0
  29. logdetective/server/utils.py +98 -32
  30. logdetective/skip_snippets.yml +12 -0
  31. logdetective/utils.py +187 -73
  32. logdetective-2.11.0.dist-info/METADATA +568 -0
  33. logdetective-2.11.0.dist-info/RECORD +40 -0
  34. {logdetective-0.4.0.dist-info → logdetective-2.11.0.dist-info}/WHEEL +1 -1
  35. logdetective/server/database/models.py +0 -88
  36. logdetective-0.4.0.dist-info/METADATA +0 -333
  37. logdetective-0.4.0.dist-info/RECORD +0 -19
  38. {logdetective-0.4.0.dist-info → logdetective-2.11.0.dist-info}/entry_points.txt +0 -0
  39. {logdetective-0.4.0.dist-info → logdetective-2.11.0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,623 @@
1
+ from __future__ import annotations
2
+ import enum
3
+ import datetime
4
+ from typing import Optional, List, Tuple, Self, TYPE_CHECKING
5
+
6
+ import backoff
7
+
8
+ from sqlalchemy import (
9
+ Enum,
10
+ BigInteger,
11
+ DateTime,
12
+ String,
13
+ ForeignKey,
14
+ UniqueConstraint,
15
+ desc,
16
+ select,
17
+ )
18
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
19
+ from sqlalchemy.engine import Row
20
+ from sqlalchemy.exc import OperationalError
21
+ from logdetective.server.database.base import Base, transaction, DB_MAX_RETRIES
22
+
23
+
24
+ if TYPE_CHECKING:
25
+ from .metrics import AnalyzeRequestMetrics
26
+
27
+
28
+ class Forge(str, enum.Enum):
29
+ """List of forges managed by logdetective"""
30
+
31
+ gitlab_com = "https://gitlab.com" # pylint: disable=(invalid-name)
32
+ gitlab_cee_redhat_com = "http://gitlab.cee.redhat.com/" # pylint: disable=(invalid-name)
33
+
34
+
35
+ class GitlabMergeRequestJobs(Base):
36
+ """Store details for the merge request jobs
37
+ which triggered logdetective.
38
+ """
39
+
40
+ __tablename__ = "gitlab_merge_request_jobs"
41
+
42
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
43
+ forge: Mapped[Forge] = mapped_column(
44
+ Enum(Forge),
45
+ nullable=False,
46
+ index=True,
47
+ comment="The forge name"
48
+ )
49
+ project_id: Mapped[int] = mapped_column(
50
+ BigInteger,
51
+ nullable=False,
52
+ index=True,
53
+ comment="The project gitlab id",
54
+ )
55
+ mr_iid: Mapped[int] = mapped_column(
56
+ BigInteger,
57
+ nullable=False,
58
+ index=False,
59
+ comment="The merge request gitlab iid",
60
+ )
61
+ job_id: Mapped[int] = mapped_column(
62
+ BigInteger,
63
+ nullable=False,
64
+ index=True,
65
+ comment="The job gitlab id",
66
+ )
67
+
68
+ __table_args__ = (
69
+ UniqueConstraint("forge", "job_id", name="uix_forge_job"),
70
+ UniqueConstraint(
71
+ "forge", "project_id", "mr_iid", "job_id", name="uix_mr_project_job"
72
+ ),
73
+ )
74
+
75
+ comment: Mapped[List["Comments"]] = relationship(
76
+ "Comments", back_populates="merge_request_job", uselist=False
77
+ ) # 1 comment for 1 job
78
+
79
+ request_metrics: Mapped[List["AnalyzeRequestMetrics"]] = relationship(
80
+ "AnalyzeRequestMetrics",
81
+ back_populates="mr_job"
82
+ )
83
+
84
+ @classmethod
85
+ @backoff.on_exception(backoff.expo, OperationalError, max_tries=DB_MAX_RETRIES)
86
+ async def create(
87
+ cls,
88
+ forge: Forge,
89
+ project_id: int,
90
+ mr_iid: int,
91
+ job_id: int,
92
+ ) -> int:
93
+ """Create a new merge request job entry,
94
+ returns its PostgreSQL id
95
+
96
+ Args:
97
+ forge: forge name
98
+ project_id: forge project id
99
+ mr_iid: merge request forge iid
100
+ job_id: forge job id
101
+ """
102
+ async with transaction(commit=True) as session:
103
+ mr = cls()
104
+ mr.forge = forge
105
+ mr.project_id = project_id
106
+ mr.mr_iid = mr_iid
107
+ mr.job_id = job_id
108
+ session.add(mr)
109
+ await session.flush()
110
+ return mr.id
111
+
112
+ @classmethod
113
+ async def get_by_id(
114
+ cls,
115
+ id_: int,
116
+ ) -> Optional["GitlabMergeRequestJobs"]:
117
+ """Search for a given PostgreSQL id"""
118
+ query = select(cls).where(cls.id == id_)
119
+ async with transaction(commit=False) as session:
120
+ query_result = await session.execute(query)
121
+ mr = query_result.scalars().first()
122
+ return mr
123
+
124
+ @classmethod
125
+ async def get_by_details(
126
+ cls,
127
+ forge: Forge,
128
+ project_id: int,
129
+ mr_iid: int,
130
+ job_id: int,
131
+ ) -> Optional["GitlabMergeRequestJobs"]:
132
+ """Search for a detailed merge request.
133
+
134
+ Args:
135
+ forge: forge name
136
+ project_id: forge project id
137
+ mr_iid: merge request forge iid
138
+ job_id: forge job id
139
+ """
140
+ query = select(cls).where(
141
+ cls.forge == forge,
142
+ cls.project_id == project_id,
143
+ cls.mr_iid == mr_iid,
144
+ cls.job_id == job_id,
145
+ )
146
+ async with transaction(commit=False) as session:
147
+ query_result = await session.execute(query)
148
+ mr = query_result.scalars().first()
149
+ return mr
150
+
151
+ @classmethod
152
+ async def get_by_mr_iid(cls, forge: Forge, project_id: int, mr_iid) -> List[Self]:
153
+ """Get all the mr jobs saved for the specified mr iid and project id."""
154
+ query = select(cls).where(
155
+ GitlabMergeRequestJobs.forge == forge,
156
+ GitlabMergeRequestJobs.project_id == project_id,
157
+ GitlabMergeRequestJobs.mr_iid == mr_iid,
158
+ )
159
+
160
+ async with transaction(commit=False) as session:
161
+ query_result = await session.execute(query)
162
+ comments = query_result.scalars().all()
163
+
164
+ return comments
165
+
166
+ @classmethod
167
+ async def get_or_create(
168
+ cls,
169
+ forge: Forge,
170
+ project_id: int,
171
+ mr_iid: int,
172
+ job_id: int,
173
+ ) -> Self:
174
+ """Search for a detailed merge request
175
+ or create a new one if not found.
176
+
177
+ Args:
178
+ forge: forge name
179
+ project_id: forge project id
180
+ mr_iid: merge request forge iid
181
+ job_id: forge job id
182
+ """
183
+ mr = await GitlabMergeRequestJobs.get_by_details(
184
+ forge, project_id, mr_iid, job_id
185
+ )
186
+ if mr is None:
187
+ id_ = await GitlabMergeRequestJobs.create(forge, project_id, mr_iid, job_id)
188
+ mr = await GitlabMergeRequestJobs.get_by_id(id_)
189
+ return mr
190
+
191
+
192
+ class Comments(Base):
193
+ """Store details for comments
194
+ created by logdetective for a merge request job."""
195
+
196
+ __tablename__ = "comments"
197
+
198
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
199
+ merge_request_job_id: Mapped[int] = mapped_column(
200
+ BigInteger,
201
+ ForeignKey("gitlab_merge_request_jobs.id"),
202
+ nullable=False,
203
+ unique=True, # 1 comment for 1 job
204
+ index=True,
205
+ comment="The associated merge request job (db) id",
206
+ )
207
+ forge: Mapped[Forge] = mapped_column(
208
+ Enum(Forge),
209
+ nullable=False,
210
+ index=True,
211
+ comment="The forge name"
212
+ )
213
+ comment_id: Mapped[str] = mapped_column(
214
+ String(50), # e.g. 'd5a3ff139356ce33e37e73add446f16869741b50'
215
+ nullable=False,
216
+ index=True,
217
+ comment="The comment gitlab id",
218
+ )
219
+ created_at: Mapped[datetime.datetime] = mapped_column(
220
+ DateTime(timezone=True),
221
+ nullable=False,
222
+ comment="Timestamp when the comment was created",
223
+ )
224
+
225
+ __table_args__ = (
226
+ UniqueConstraint("forge", "comment_id", name="uix_forge_comment_id"),
227
+ )
228
+
229
+ merge_request_job: Mapped["GitlabMergeRequestJobs"] = relationship(
230
+ "GitlabMergeRequestJobs",
231
+ back_populates="comment"
232
+ )
233
+ reactions: Mapped[list["Reactions"]] = relationship("Reactions", back_populates="comment")
234
+
235
+ @classmethod
236
+ @backoff.on_exception(backoff.expo, OperationalError, max_tries=DB_MAX_RETRIES)
237
+ async def create( # pylint: disable=too-many-arguments disable=too-many-positional-arguments
238
+ cls,
239
+ forge: Forge,
240
+ project_id: int,
241
+ mr_iid: int,
242
+ job_id: int,
243
+ comment_id: str,
244
+ metrics: Optional["AnalyzeRequestMetrics"] = None, # noqa: F821
245
+ ) -> int:
246
+ """Create a new comment id entry,
247
+ returns its PostgreSQL id.
248
+ A Gitlab comment id is unique within a
249
+ gitlab instance.
250
+
251
+ Args:
252
+ forge: forge name
253
+ project_id: forge project id
254
+ mr_iid: merge request forge iid
255
+ job_id: forge job id
256
+ comment_id: forge comment id
257
+ """
258
+ async with transaction(commit=True) as session:
259
+ mr_job = await GitlabMergeRequestJobs.get_or_create(
260
+ forge, project_id, mr_iid, job_id
261
+ )
262
+
263
+ if metrics:
264
+ metrics.mr_job = mr_job
265
+ session.add(metrics)
266
+
267
+ comment = cls()
268
+ comment.forge = forge
269
+ comment.comment_id = comment_id
270
+ comment.created_at = datetime.datetime.now(datetime.timezone.utc)
271
+ comment.merge_request_job_id = mr_job.id
272
+ session.add(comment)
273
+ await session.flush()
274
+ return comment.id
275
+
276
+ @classmethod
277
+ async def get_by_id(
278
+ cls,
279
+ id_: int,
280
+ ) -> Optional["Comments"]:
281
+ """Search for a given PostgreSQL id"""
282
+ query = select(cls).where(cls.id == id_)
283
+ async with transaction(commit=False) as session:
284
+ query_result = await session.execute(query)
285
+ comment = query_result.scalars().first()
286
+ return comment
287
+
288
+ @classmethod
289
+ async def get_by_gitlab_id(
290
+ cls,
291
+ forge: Forge,
292
+ comment_id: str,
293
+ ) -> Optional[Self]:
294
+ """Search for a detailed comment
295
+ by its unique forge comment id.
296
+
297
+ Args:
298
+ forge: forge name
299
+ comment_id: forge comment id
300
+ """
301
+ query = (
302
+ select(cls)
303
+ .join(
304
+ GitlabMergeRequestJobs,
305
+ cls.merge_request_job_id == GitlabMergeRequestJobs.id,
306
+ )
307
+ .filter(GitlabMergeRequestJobs.forge == forge, cls.comment_id == comment_id)
308
+ )
309
+ async with transaction(commit=False) as session:
310
+ query_result = await session.execute(query)
311
+ comment = query_result.scalars().first()
312
+ return comment
313
+
314
+ @classmethod
315
+ async def get_latest_comment(
316
+ cls,
317
+ forge: Forge,
318
+ project_id: int,
319
+ mr_iid: int,
320
+ ) -> Optional["Comments"]:
321
+ """Search for the latest comment in the merge request.
322
+
323
+ Args:
324
+ forge: forge name
325
+ project_id: forge project id
326
+ mr_iid: merge request forge iid
327
+ """
328
+ query = (
329
+ select(cls)
330
+ .join(
331
+ GitlabMergeRequestJobs,
332
+ cls.merge_request_job_id == GitlabMergeRequestJobs.id,
333
+ )
334
+ .filter(
335
+ GitlabMergeRequestJobs.forge == forge,
336
+ GitlabMergeRequestJobs.project_id == project_id,
337
+ GitlabMergeRequestJobs.mr_iid == mr_iid,
338
+ )
339
+ .order_by(desc(cls.created_at))
340
+ )
341
+ async with transaction(commit=False) as session:
342
+ query_result = await session.execute(query)
343
+ comment = query_result.scalars().first()
344
+ return comment
345
+
346
+ @classmethod
347
+ async def get_mr_comments(
348
+ cls,
349
+ forge: Forge,
350
+ project_id: int,
351
+ mr_iid: int,
352
+ ) -> List[Self]:
353
+ """Search for all merge request comments.
354
+
355
+ Args:
356
+ forge: forge name
357
+ project_id: forge project id
358
+ mr_iid: merge request forge iid
359
+ """
360
+ query = (
361
+ select(cls)
362
+ .join(
363
+ GitlabMergeRequestJobs,
364
+ cls.merge_request_job_id == GitlabMergeRequestJobs.id,
365
+ )
366
+ .filter(
367
+ GitlabMergeRequestJobs.forge == forge,
368
+ GitlabMergeRequestJobs.project_id == project_id,
369
+ GitlabMergeRequestJobs.mr_iid == mr_iid,
370
+ )
371
+ .order_by(desc(cls.created_at))
372
+ )
373
+ async with transaction(commit=False) as session:
374
+ query_result = await session.execute(query)
375
+ comments = query_result.scalars().all()
376
+ return comments
377
+
378
+ @classmethod
379
+ async def get_or_create( # pylint: disable=too-many-arguments disable=too-many-positional-arguments
380
+ cls,
381
+ forge: Forge,
382
+ project_id: int,
383
+ mr_iid: int,
384
+ job_id: int,
385
+ comment_id: str,
386
+ ) -> Self:
387
+ """Search for a detailed comment
388
+ or create a new one if not found.
389
+
390
+ Args:
391
+ forge: forge name
392
+ project_id: forge project id
393
+ mr_iid: merge request forge iid
394
+ job_id: forge job id
395
+ comment_id: forge comment id
396
+ """
397
+ comment = await Comments.get_by_gitlab_id(forge, comment_id)
398
+ if comment is None:
399
+ id_ = await Comments.create(forge, project_id, mr_iid, job_id, comment_id)
400
+ comment = await Comments.get_by_id(id_)
401
+ return comment
402
+
403
+ @classmethod
404
+ async def get_since(cls, time: datetime.datetime) -> List[Self]:
405
+ """Get all the comments created after the given time."""
406
+ query = select(cls).filter(Comments.created_at > time)
407
+ async with transaction(commit=False) as session:
408
+ query_result = await session.execute(query)
409
+ comments = query_result.scalars().all()
410
+
411
+ return comments
412
+
413
+ @classmethod
414
+ async def get_by_mr_job(
415
+ cls, merge_request_job: GitlabMergeRequestJobs
416
+ ) -> Optional["Comments"]:
417
+ """Get the comment added for the specified merge request's job."""
418
+ query = select(cls).filter(Comments.merge_request_job == merge_request_job)
419
+ async with transaction(commit=False) as session:
420
+ query_result = await session.execute(query)
421
+ comments = query_result.scalars().first()
422
+ return comments
423
+
424
+
425
+ class Reactions(Base):
426
+ """Store and count reactions received for
427
+ logdetective comments"""
428
+
429
+ __tablename__ = "reactions"
430
+
431
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
432
+ comment_id: Mapped[int] = mapped_column(
433
+ BigInteger,
434
+ ForeignKey("comments.id"),
435
+ nullable=False,
436
+ index=True,
437
+ comment="The associated comment (db) id",
438
+ )
439
+ reaction_type: Mapped[str] = mapped_column(
440
+ String(127), # e.g. 'thumbs-up'
441
+ nullable=False,
442
+ comment="The type of reaction",
443
+ )
444
+ count: Mapped[int] = mapped_column(
445
+ BigInteger,
446
+ nullable=False,
447
+ comment="The number of reactions, of this type, given in the comment",
448
+ )
449
+
450
+ __table_args__ = (
451
+ UniqueConstraint("comment_id", "reaction_type", name="uix_comment_reaction"),
452
+ )
453
+
454
+ comment: Mapped["Comments"] = relationship("Comments", back_populates="reactions")
455
+
456
+ @classmethod
457
+ @backoff.on_exception(backoff.expo, OperationalError, max_tries=DB_MAX_RETRIES)
458
+ async def create_or_update( # pylint: disable=too-many-arguments disable=too-many-positional-arguments
459
+ cls,
460
+ forge: Forge,
461
+ project_id: int,
462
+ mr_iid: int,
463
+ job_id: int,
464
+ comment_id: str,
465
+ reaction_type: str,
466
+ count: int,
467
+ ) -> int:
468
+ """Create or update the given reaction, and its associated count,
469
+ for a specified comment.
470
+
471
+ Args:
472
+ forge: forge name
473
+ project_id: forge project id
474
+ mr_iid: merge request forge iid
475
+ job_id: forge job id
476
+ comment_id: forge comment id
477
+ reaction_type: a str, ex. thumb_up
478
+ count: number of reactions, of this type, given in the comment
479
+ """
480
+ comment = await Comments.get_or_create(
481
+ forge, project_id, mr_iid, job_id, comment_id
482
+ )
483
+ reaction = await cls.get_reaction_by_type(
484
+ forge, project_id, mr_iid, job_id, comment_id, reaction_type
485
+ )
486
+ async with transaction(commit=True) as session:
487
+ if reaction:
488
+ reaction.count = count # just update
489
+ else:
490
+ reaction = cls()
491
+ reaction.comment_id = comment.id
492
+ reaction.reaction_type = reaction_type
493
+ reaction.count = count
494
+ session.add(reaction)
495
+ await session.flush()
496
+ return reaction.id
497
+
498
+ @classmethod
499
+ async def get_all_reactions( # pylint: disable=too-many-arguments disable=too-many-positional-arguments
500
+ cls,
501
+ forge: Forge,
502
+ project_id: int,
503
+ mr_iid: int,
504
+ job_id: int,
505
+ comment_id: str,
506
+ ) -> List[Self]:
507
+ """Get all reactions for a comment
508
+
509
+ Args:
510
+ forge: forge name
511
+ project_id: forge project id
512
+ mr_iid: merge request forge iid
513
+ job_id: forge job id
514
+ comment_id: forge comment id
515
+ """
516
+ query = (
517
+ select(cls)
518
+ .join(Comments, cls.comment_id == Comments.id)
519
+ .join(
520
+ GitlabMergeRequestJobs,
521
+ Comments.merge_request_job_id == GitlabMergeRequestJobs.id,
522
+ )
523
+ .filter(
524
+ Comments.comment_id == comment_id,
525
+ GitlabMergeRequestJobs.forge == forge,
526
+ GitlabMergeRequestJobs.project_id == project_id,
527
+ GitlabMergeRequestJobs.mr_iid == mr_iid,
528
+ GitlabMergeRequestJobs.job_id == job_id,
529
+ )
530
+ )
531
+ async with transaction(commit=False) as session:
532
+ query_result = await session.execute(query)
533
+ reactions = query_result.scalars().all()
534
+ return reactions
535
+
536
+ @classmethod
537
+ async def get_reaction_by_type( # pylint: disable=too-many-arguments disable=too-many-positional-arguments
538
+ cls,
539
+ forge: Forge,
540
+ project_id: int,
541
+ mr_iid: int,
542
+ job_id: int,
543
+ comment_id: str,
544
+ reaction_type: str,
545
+ ) -> Self | None:
546
+ """Get reaction, of a given type,
547
+ for a comment
548
+
549
+ Args:
550
+ forge: forge name
551
+ project_id: forge project id
552
+ mr_iid: merge request forge iid
553
+ job_id: forge job id
554
+ comment_id: forge comment id
555
+ reaction_type: str like "thumb-up"
556
+ """
557
+ query = (
558
+ select(cls)
559
+ .join(Comments, cls.comment_id == Comments.id)
560
+ .join(
561
+ GitlabMergeRequestJobs,
562
+ Comments.merge_request_job_id == GitlabMergeRequestJobs.id,
563
+ )
564
+ .filter(
565
+ Comments.comment_id == comment_id,
566
+ GitlabMergeRequestJobs.forge == forge,
567
+ GitlabMergeRequestJobs.project_id == project_id,
568
+ GitlabMergeRequestJobs.mr_iid == mr_iid,
569
+ GitlabMergeRequestJobs.job_id == job_id,
570
+ Reactions.reaction_type == reaction_type,
571
+ )
572
+ )
573
+ async with transaction(commit=False) as session:
574
+ query_result = await session.execute(query)
575
+ reaction = query_result.scalars().first()
576
+ return reaction
577
+
578
+ @classmethod
579
+ @backoff.on_exception(backoff.expo, OperationalError, max_tries=DB_MAX_RETRIES)
580
+ async def delete( # pylint: disable=too-many-arguments disable=too-many-positional-arguments
581
+ cls,
582
+ forge: Forge,
583
+ project_id: int,
584
+ mr_iid: int,
585
+ job_id: int,
586
+ comment_id: str,
587
+ reaction_types: List[str],
588
+ ) -> None:
589
+ """Remove rows with given reaction types
590
+
591
+ Args:
592
+ forge: forge name
593
+ project_id: forge project id
594
+ mr_iid: merge request forge iid
595
+ job_id: forge job id
596
+ comment_id: forge comment id
597
+ reaction_type: a str iterable, ex. ['thumbsup', 'thumbsdown']
598
+ """
599
+ for reaction_type in reaction_types:
600
+ reaction = await cls.get_reaction_by_type(
601
+ forge, project_id, mr_iid, job_id, comment_id, reaction_type
602
+ )
603
+ async with transaction(commit=True) as session:
604
+ await session.delete(reaction)
605
+ await session.flush()
606
+
607
+ @classmethod
608
+ async def get_since(
609
+ cls, time: datetime.datetime
610
+ ) -> List[Row[Tuple[datetime.datetime, Self]]]:
611
+ """Get all the reactions on comments created after the given time
612
+ and the comment creation time."""
613
+ query = (
614
+ select(Comments.created_at, cls)
615
+ .join(Comments, cls.comment_id == Comments.id)
616
+ .filter(Comments.created_at > time)
617
+ )
618
+
619
+ async with transaction(commit=False) as session:
620
+ query_results = await session.execute(query)
621
+ reactions = query_results.all()
622
+
623
+ return reactions