logdetective 0.9.1__py3-none-any.whl → 0.10.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.
@@ -1,76 +1,59 @@
1
- import asyncio
2
- import json
3
1
  import os
4
- import re
5
- import zipfile
2
+ import asyncio
3
+ import datetime
6
4
  from enum import Enum
7
5
  from contextlib import asynccontextmanager
8
- from pathlib import Path, PurePath
9
- from tempfile import TemporaryFile
10
- from typing import List, Annotated, Tuple, Dict, Any, Union
6
+ from typing import Annotated
11
7
  from io import BytesIO
12
8
 
13
- import backoff
14
9
  import matplotlib
15
10
  import matplotlib.pyplot
16
- from aiohttp import StreamReader
17
11
  from fastapi import FastAPI, HTTPException, BackgroundTasks, Depends, Header, Request
18
12
 
19
13
  from fastapi.responses import StreamingResponse
20
14
  from fastapi.responses import Response as BasicResponse
21
- import gitlab
22
- import gitlab.v4
23
- import gitlab.v4.objects
24
- import jinja2
25
15
  import aiohttp
26
- import sqlalchemy
27
16
  import sentry_sdk
28
17
 
29
18
  import logdetective.server.database.base
30
19
 
31
- from logdetective.extractors import DrainExtractor
32
20
  from logdetective.utils import (
33
21
  compute_certainty,
34
22
  format_snippets,
35
- load_prompts,
36
23
  )
37
- from logdetective.server.utils import (
38
- load_server_config,
39
- get_log,
40
- format_analyzed_snippets,
24
+
25
+ from logdetective.server.config import SERVER_CONFIG, PROMPT_CONFIG, LOG
26
+ from logdetective.remote_log import RemoteLog
27
+ from logdetective.server.llm import (
28
+ mine_logs,
29
+ perform_staged_analysis,
30
+ submit_text,
31
+ submit_text_chat_completions,
41
32
  )
42
- from logdetective.server.metric import track_request, add_new_metrics, update_metrics
33
+ from logdetective.server.gitlab import process_gitlab_job_event
34
+ from logdetective.server.metric import track_request
43
35
  from logdetective.server.models import (
44
36
  BuildLog,
37
+ EmojiHook,
45
38
  JobHook,
46
39
  Response,
47
40
  StagedResponse,
48
- Explanation,
49
- AnalyzedSnippet,
50
41
  TimePeriod,
51
42
  )
52
43
  from logdetective.server import plot as plot_engine
53
- from logdetective.server.remote_log import RemoteLog
54
44
  from logdetective.server.database.models import (
55
- Comments,
56
45
  EndpointType,
57
46
  Forge,
58
47
  )
59
- from logdetective.server.database.models import AnalyzeRequestMetrics
48
+ from logdetective.server.emoji import (
49
+ collect_emojis,
50
+ collect_emojis_for_mr,
51
+ )
52
+
60
53
 
61
- LLM_CPP_SERVER_TIMEOUT = os.environ.get("LLAMA_CPP_SERVER_TIMEOUT", 600)
62
54
  LOG_SOURCE_REQUEST_TIMEOUT = os.environ.get("LOG_SOURCE_REQUEST_TIMEOUT", 60)
63
55
  API_TOKEN = os.environ.get("LOGDETECTIVE_TOKEN", None)
64
- SERVER_CONFIG_PATH = os.environ.get("LOGDETECTIVE_SERVER_CONF", None)
65
- SERVER_PROMPT_PATH = os.environ.get("LOGDETECTIVE_PROMPTS", None)
66
56
 
67
- SERVER_CONFIG = load_server_config(SERVER_CONFIG_PATH)
68
- PROMPT_CONFIG = load_prompts(SERVER_PROMPT_PATH)
69
-
70
- MR_REGEX = re.compile(r"refs/merge-requests/(\d+)/.*$")
71
- FAILURE_LOG_REGEX = re.compile(r"(\w*\.log)")
72
-
73
- LOG = get_log(SERVER_CONFIG)
74
57
 
75
58
  if sentry_dsn := SERVER_CONFIG.general.sentry_dsn:
76
59
  sentry_sdk.init(dsn=str(sentry_dsn), traces_sample_rate=1.0)
@@ -90,7 +73,11 @@ async def lifespan(fapp: FastAPI):
90
73
  # Ensure that the database is initialized.
91
74
  logdetective.server.database.base.init()
92
75
 
76
+ # Start the background task scheduler for collecting emojis
77
+ asyncio.create_task(schedule_collect_emojis_task())
78
+
93
79
  yield
80
+
94
81
  await fapp.http.close()
95
82
 
96
83
 
@@ -132,203 +119,6 @@ def requires_token_when_set(authentication: Annotated[str | None, Header()] = No
132
119
 
133
120
 
134
121
  app = FastAPI(dependencies=[Depends(requires_token_when_set)], lifespan=lifespan)
135
- app.gitlab_conn = gitlab.Gitlab(
136
- url=SERVER_CONFIG.gitlab.url, private_token=SERVER_CONFIG.gitlab.api_token
137
- )
138
-
139
-
140
- def mine_logs(log: str) -> List[Tuple[int, str]]:
141
- """Extract snippets from log text"""
142
- extractor = DrainExtractor(
143
- verbose=True, context=True, max_clusters=SERVER_CONFIG.extractor.max_clusters
144
- )
145
-
146
- LOG.info("Getting summary")
147
- log_summary = extractor(log)
148
-
149
- ratio = len(log_summary) / len(log.split("\n"))
150
- LOG.debug("Log summary: \n %s", log_summary)
151
- LOG.info("Compression ratio: %s", ratio)
152
-
153
- return log_summary
154
-
155
-
156
- async def submit_to_llm_endpoint(
157
- http: aiohttp.ClientSession,
158
- url: str,
159
- data: Dict[str, Any],
160
- headers: Dict[str, str],
161
- stream: bool,
162
- ) -> Any:
163
- """Send request to selected API endpoint. Verifying successful request unless
164
- the using the stream response.
165
-
166
- url:
167
- data:
168
- headers:
169
- stream:
170
- """
171
- LOG.debug("async request %s headers=%s data=%s", url, headers, data)
172
- response = await http.post(
173
- url,
174
- headers=headers,
175
- # we need to use the `json=` parameter here and let aiohttp
176
- # handle the json-encoding
177
- json=data,
178
- timeout=int(LLM_CPP_SERVER_TIMEOUT),
179
- # Docs says chunked takes int, but:
180
- # DeprecationWarning: Chunk size is deprecated #1615
181
- # So let's make sure we either put True or None here
182
- chunked=True if stream else None,
183
- raise_for_status=True,
184
- )
185
- if stream:
186
- return response
187
- try:
188
- return json.loads(await response.text())
189
- except UnicodeDecodeError as ex:
190
- LOG.error("Error encountered while parsing llama server response: %s", ex)
191
- raise HTTPException(
192
- status_code=400,
193
- detail=f"Couldn't parse the response.\nError: {ex}\nData: {response.text}",
194
- ) from ex
195
-
196
-
197
- def should_we_giveup(exc: aiohttp.ClientResponseError) -> bool:
198
- """
199
- From backoff's docs:
200
-
201
- > a function which accepts the exception and returns
202
- > a truthy value if the exception should not be retried
203
- """
204
- LOG.info("Should we give up on retrying error %s", exc)
205
- return exc.status < 500
206
-
207
-
208
- def we_give_up(details: backoff._typing.Details):
209
- """
210
- retries didn't work (or we got a different exc)
211
- we give up and raise proper 500 for our API endpoint
212
- """
213
- LOG.error("Inference error: %s", details["args"])
214
- raise HTTPException(500, "Request to the inference API failed")
215
-
216
-
217
- @backoff.on_exception(
218
- backoff.expo,
219
- aiohttp.ClientResponseError,
220
- max_tries=3,
221
- giveup=should_we_giveup,
222
- raise_on_giveup=False,
223
- on_giveup=we_give_up,
224
- )
225
- async def submit_text( # pylint: disable=R0913,R0917
226
- http: aiohttp.ClientSession,
227
- text: str,
228
- max_tokens: int = -1,
229
- log_probs: int = 1,
230
- stream: bool = False,
231
- model: str = "default-model",
232
- ) -> Explanation:
233
- """Submit prompt to LLM using a selected endpoint.
234
- max_tokens: number of tokens to be produces, 0 indicates run until encountering EOS
235
- log_probs: number of token choices to produce log probs for
236
- """
237
- LOG.info("Analyzing the text")
238
-
239
- headers = {"Content-Type": "application/json"}
240
-
241
- if SERVER_CONFIG.inference.api_token:
242
- headers["Authorization"] = f"Bearer {SERVER_CONFIG.inference.api_token}"
243
-
244
- if SERVER_CONFIG.inference.api_endpoint == "/chat/completions":
245
- return await submit_text_chat_completions(
246
- http, text, headers, max_tokens, log_probs > 0, stream, model
247
- )
248
- return await submit_text_completions(
249
- http, text, headers, max_tokens, log_probs, stream, model
250
- )
251
-
252
-
253
- async def submit_text_completions( # pylint: disable=R0913,R0917
254
- http: aiohttp.ClientSession,
255
- text: str,
256
- headers: dict,
257
- max_tokens: int = -1,
258
- log_probs: int = 1,
259
- stream: bool = False,
260
- model: str = "default-model",
261
- ) -> Explanation:
262
- """Submit prompt to OpenAI API completions endpoint.
263
- max_tokens: number of tokens to be produces, 0 indicates run until encountering EOS
264
- log_probs: number of token choices to produce log probs for
265
- """
266
- LOG.info("Submitting to /v1/completions endpoint")
267
- data = {
268
- "prompt": text,
269
- "max_tokens": max_tokens,
270
- "logprobs": log_probs,
271
- "stream": stream,
272
- "model": model,
273
- "temperature": SERVER_CONFIG.inference.temperature,
274
- }
275
-
276
- response = await submit_to_llm_endpoint(
277
- http,
278
- f"{SERVER_CONFIG.inference.url}/v1/completions",
279
- data,
280
- headers,
281
- stream,
282
- )
283
-
284
- return Explanation(
285
- text=response["choices"][0]["text"], logprobs=response["choices"][0]["logprobs"]
286
- )
287
-
288
-
289
- async def submit_text_chat_completions( # pylint: disable=R0913,R0917
290
- http: aiohttp.ClientSession,
291
- text: str,
292
- headers: dict,
293
- max_tokens: int = -1,
294
- log_probs: int = 1,
295
- stream: bool = False,
296
- model: str = "default-model",
297
- ) -> Union[Explanation, StreamReader]:
298
- """Submit prompt to OpenAI API /chat/completions endpoint.
299
- max_tokens: number of tokens to be produces, 0 indicates run until encountering EOS
300
- log_probs: number of token choices to produce log probs for
301
- """
302
- LOG.info("Submitting to /v1/chat/completions endpoint")
303
-
304
- data = {
305
- "messages": [
306
- {
307
- "role": "user",
308
- "content": text,
309
- }
310
- ],
311
- "max_tokens": max_tokens,
312
- "logprobs": log_probs,
313
- "stream": stream,
314
- "model": model,
315
- "temperature": SERVER_CONFIG.inference.temperature,
316
- }
317
-
318
- response = await submit_to_llm_endpoint(
319
- http,
320
- f"{SERVER_CONFIG.inference.url}/v1/chat/completions",
321
- data,
322
- headers,
323
- stream,
324
- )
325
-
326
- if stream:
327
- return response
328
- return Explanation(
329
- text=response["choices"][0]["message"]["content"],
330
- logprobs=response["choices"][0]["logprobs"]["content"],
331
- )
332
122
 
333
123
 
334
124
  @app.post("/analyze", response_model=Response)
@@ -346,6 +136,7 @@ async def analyze_log(
346
136
  log_text = await remote_log.process_url()
347
137
  log_summary = mine_logs(log_text)
348
138
  log_summary = format_snippets(log_summary)
139
+
349
140
  response = await submit_text(
350
141
  http_session,
351
142
  PROMPT_CONFIG.prompt_template.format(log_summary),
@@ -381,61 +172,27 @@ async def analyze_log_staged(
381
172
  remote_log = RemoteLog(build_log.url, http_session)
382
173
  log_text = await remote_log.process_url()
383
174
 
384
- return await perform_staged_analysis(http_session, log_text=log_text)
385
-
386
-
387
- async def perform_staged_analysis(
388
- http: aiohttp.ClientSession, log_text: str
389
- ) -> StagedResponse:
390
- """Submit the log file snippets to the LLM and retrieve their results"""
391
- log_summary = mine_logs(log_text)
392
-
393
- # Process snippets asynchronously
394
- analyzed_snippets = await asyncio.gather(
395
- *[
396
- submit_text(
397
- http,
398
- PROMPT_CONFIG.snippet_prompt_template.format(s),
399
- model=SERVER_CONFIG.inference.model,
400
- max_tokens=SERVER_CONFIG.inference.max_tokens,
401
- )
402
- for s in log_summary
403
- ]
175
+ return await perform_staged_analysis(
176
+ http_session,
177
+ log_text=log_text,
404
178
  )
405
179
 
406
- analyzed_snippets = [
407
- AnalyzedSnippet(line_number=e[0][0], text=e[0][1], explanation=e[1])
408
- for e in zip(log_summary, analyzed_snippets)
409
- ]
410
- final_prompt = PROMPT_CONFIG.prompt_template_staged.format(
411
- format_analyzed_snippets(analyzed_snippets)
412
- )
413
180
 
414
- final_analysis = await submit_text(
415
- http,
416
- final_prompt,
417
- model=SERVER_CONFIG.inference.model,
418
- max_tokens=SERVER_CONFIG.inference.max_tokens,
419
- )
181
+ @app.get("/queue/print")
182
+ async def queue_print(msg: str):
183
+ """Debug endpoint to test the LLM request queue"""
184
+ LOG.info("Will print %s", msg)
420
185
 
421
- certainty = 0
186
+ result = await async_log(msg)
422
187
 
423
- if final_analysis.logprobs:
424
- try:
425
- certainty = compute_certainty(final_analysis.logprobs)
426
- except ValueError as ex:
427
- LOG.error("Error encountered while computing certainty: %s", ex)
428
- raise HTTPException(
429
- status_code=400,
430
- detail=f"Couldn't compute certainty with data:\n"
431
- f"{final_analysis.logprobs}",
432
- ) from ex
188
+ LOG.info("Printed %s and returned it", result)
433
189
 
434
- return StagedResponse(
435
- explanation=final_analysis,
436
- snippets=analyzed_snippets,
437
- response_certainty=certainty,
438
- )
190
+
191
+ async def async_log(msg):
192
+ """Debug function to test the LLM request queue"""
193
+ async with SERVER_CONFIG.inference.get_limiter():
194
+ LOG.critical(msg)
195
+ return msg
439
196
 
440
197
 
441
198
  @app.post("/analyze/stream", response_class=StreamingResponse)
@@ -459,7 +216,7 @@ async def analyze_log_stream(
459
216
  headers["Authorization"] = f"Bearer {SERVER_CONFIG.inference.api_token}"
460
217
 
461
218
  try:
462
- stream = await submit_text_chat_completions(
219
+ stream = submit_text_chat_completions(
463
220
  http_session,
464
221
  PROMPT_CONFIG.prompt_template.format(log_summary),
465
222
  stream=True,
@@ -497,387 +254,125 @@ async def receive_gitlab_job_event_webhook(
497
254
  LOG.critical("%s is not a recognized forge. Ignoring.", x_gitlab_instance)
498
255
  return BasicResponse(status_code=400)
499
256
 
500
- # Handle the message in the background so we can return 200 immediately
501
- background_tasks.add_task(process_gitlab_job_event, http, forge, job_hook)
257
+ # Handle the message in the background so we can return 204 immediately
258
+ gitlab_cfg = SERVER_CONFIG.gitlab.instances[forge.value]
259
+ background_tasks.add_task(
260
+ process_gitlab_job_event,
261
+ http,
262
+ gitlab_cfg,
263
+ forge,
264
+ job_hook,
265
+ )
502
266
 
503
267
  # No return value or body is required for a webhook.
504
268
  # 204: No Content
505
269
  return BasicResponse(status_code=204)
506
270
 
507
271
 
508
- async def process_gitlab_job_event(
509
- http: aiohttp.ClientSession,
510
- forge: Forge,
511
- job_hook: JobHook,
512
- ):
513
- """Handle a received job_event webhook from GitLab"""
514
- LOG.debug("Received webhook message from %s:\n%s", forge.value, job_hook)
515
-
516
- # Look up the project this job belongs to
517
- project = await asyncio.to_thread(app.gitlab_conn.projects.get, job_hook.project_id)
518
- LOG.info("Processing failed job for %s", project.name)
272
+ # A lookup table for whether we are currently processing a given merge request
273
+ # The key is the tuple (Forge, ProjectID, MRID) and the value is a boolean
274
+ # indicating whether we need to re-trigger the lookup immediately after
275
+ # completion due to another request coming in during processing.
276
+ # For example: {("https://gitlab.example.com", 23, 2): False}
277
+ emoji_lookup = {}
519
278
 
520
- # Retrieve data about the job from the GitLab API
521
- job = await asyncio.to_thread(project.jobs.get, job_hook.build_id)
522
279
 
523
- # For easy retrieval later, we'll add project_name and project_url to the
524
- # job object
525
- job.project_name = project.name
526
- job.project_url = project.web_url
280
+ @app.post("/webhook/gitlab/emoji_events")
281
+ async def receive_gitlab_emoji_event_webhook(
282
+ x_gitlab_instance: Annotated[str | None, Header()],
283
+ emoji_hook: EmojiHook,
284
+ background_tasks: BackgroundTasks,
285
+ ):
286
+ """Webhook endpoint for receiving emoji event notifications from Gitlab
287
+ https://docs.gitlab.com/user/project/integrations/webhook_events/#emoji-events
288
+ lists the full specification for the messages sent for emoji events"""
527
289
 
528
- # Retrieve the pipeline that started this job
529
- pipeline = await asyncio.to_thread(project.pipelines.get, job_hook.pipeline_id)
290
+ try:
291
+ forge = Forge(x_gitlab_instance)
292
+ except ValueError:
293
+ LOG.critical("%s is not a recognized forge. Ignoring.", x_gitlab_instance)
294
+ return BasicResponse(status_code=400)
530
295
 
531
- # Verify this is a merge request
532
- if pipeline.source != "merge_request_event":
533
- LOG.info("Not a merge request pipeline. Ignoring.")
534
- return
296
+ if not emoji_hook.merge_request:
297
+ # This is not a merge request event. It is probably an emoji applied
298
+ # to some other "awardable" entity. Just ignore it and return.
299
+ LOG.debug("Emoji event is not related to a merge request. Ignoring.")
300
+ return BasicResponse(status_code=204)
535
301
 
536
- # Extract the merge-request ID from the job
537
- match = MR_REGEX.search(pipeline.ref)
538
- if not match:
539
- LOG.error(
540
- "Pipeline source is merge_request_event but no merge request ID was provided."
541
- )
542
- return
543
- merge_request_iid = int(match.group(1))
302
+ # We will re-process all the emojis on this merge request, to ensure that
303
+ # we haven't missed any messages, since webhooks do not provide delivery
304
+ # guarantees.
544
305
 
545
- LOG.debug("Retrieving log artifacts")
546
- # Retrieve the build logs from the merge request artifacts and preprocess them
547
- try:
548
- log_url, preprocessed_log = await retrieve_and_preprocess_koji_logs(http, job)
549
- except LogsTooLargeError:
550
- LOG.error("Could not retrieve logs. Too large.")
551
- raise
552
-
553
- # Submit log to Log Detective and await the results.
554
- log_text = preprocessed_log.read().decode(encoding="utf-8")
555
- metrics_id = await add_new_metrics(
556
- api_name=EndpointType.ANALYZE_GITLAB_JOB,
557
- url=log_url,
558
- http_session=http,
559
- compressed_log_content=RemoteLog.zip_text(log_text),
306
+ # Check whether this request is already in progress.
307
+ # We are single-threaded, so we can guarantee that the table won't change
308
+ # between here and when we schedule the lookup.
309
+ key = (
310
+ forge,
311
+ emoji_hook.merge_request.target_project_id,
312
+ emoji_hook.merge_request.iid,
560
313
  )
561
- staged_response = await perform_staged_analysis(http, log_text=log_text)
562
- update_metrics(metrics_id, staged_response)
563
- preprocessed_log.close()
564
-
565
- # check if this project is on the opt-in list for posting comments.
566
- if project.name not in SERVER_CONFIG.general.packages:
567
- LOG.info("Not publishing comment for unrecognized package %s", project.name)
568
- return
569
-
570
- # Add the Log Detective response as a comment to the merge request
571
- await comment_on_mr(
314
+ if key in emoji_lookup:
315
+ # It's already in progress, so we do not want to start another pass
316
+ # concurrently. We'll set the value to True to indicate that we should
317
+ # re-enqueue this lookup after the currently-running one concludes. It
318
+ # is always safe to set this to True, even if it's already True. If
319
+ # multiple requests come in during processing, we only need to re-run
320
+ # it a single time, since it will pick up all the ongoing changes. The
321
+ # worst-case situation is the one where we receive new requests just
322
+ # after processing starts, which will cause the cycle to repeat again.
323
+ # This should be very infrequent, as emoji events are computationally
324
+ # rare and very quick to process.
325
+ emoji_lookup[key] = True
326
+ LOG.info("MR Emojis already being processed for %s. Rescheduling.", key)
327
+ return BasicResponse(status_code=204)
328
+
329
+ # Inform the lookup table that we are processing this emoji
330
+ emoji_lookup[key] = False
331
+
332
+ # Create a background task to process the emojis on this Merge Request.
333
+ background_tasks.add_task(
334
+ schedule_emoji_collection_for_mr,
572
335
  forge,
573
- project,
574
- merge_request_iid,
575
- job,
576
- log_url,
577
- staged_response,
578
- metrics_id,
336
+ emoji_hook.merge_request.target_project_id,
337
+ emoji_hook.merge_request.iid,
338
+ background_tasks,
579
339
  )
580
340
 
581
- return staged_response
582
-
583
-
584
- class LogsTooLargeError(RuntimeError):
585
- """The log archive exceeds the configured maximum size"""
586
-
587
-
588
- async def retrieve_and_preprocess_koji_logs(
589
- http: aiohttp.ClientSession, job: gitlab.v4.objects.ProjectJob
590
- ): # pylint: disable=too-many-branches
591
- """Download logs from the merge request artifacts
592
-
593
- This function will retrieve the build logs and do some minimal
594
- preprocessing to determine which log is relevant for analysis.
595
-
596
- returns: The URL pointing to the selected log file and an open, file-like
597
- object containing the log contents to be sent for processing by Log
598
- Detective. The calling function is responsible for closing this object."""
599
-
600
- # Make sure the file isn't too large to process.
601
- if not await check_artifacts_file_size(http, job):
602
- raise LogsTooLargeError(
603
- f"Oversized logs for job {job.id} in project {job.project_id}"
604
- )
605
-
606
- # Create a temporary file to store the downloaded log zipfile.
607
- # This will be automatically deleted when the last reference into it
608
- # (returned by this function) is closed.
609
- tempfile = TemporaryFile(mode="w+b")
610
- await asyncio.to_thread(job.artifacts, streamed=True, action=tempfile.write)
611
- tempfile.seek(0)
612
-
613
- failed_arches = {}
614
- artifacts_zip = zipfile.ZipFile(tempfile, mode="r") # pylint: disable=consider-using-with
615
- for zipinfo in artifacts_zip.infolist():
616
- if zipinfo.filename.endswith("task_failed.log"):
617
- # The koji logs store this file in two places: 1) in the
618
- # directory with the failed architecture and 2) in the parent
619
- # directory. Most of the time, we want to ignore the one in the
620
- # parent directory, since the rest of the information is in the
621
- # specific task directory. However, there are some situations
622
- # where non-build failures (such as "Target build already exists")
623
- # may be presented only at the top level.
624
- # The paths look like `kojilogs/noarch-XXXXXX/task_failed.log`
625
- # or `kojilogs/noarch-XXXXXX/x86_64-XXXXXX/task_failed.log`
626
- path = PurePath(zipinfo.filename)
627
- if len(path.parts) <= 3:
628
- failed_arches["toplevel"] = path
629
- continue
630
-
631
- # Extract the architecture from the immediate parent path
632
- architecture = path.parent.parts[-1].split("-")[0]
633
-
634
- # Open this file and read which log failed.
635
- # The string in this log has the format
636
- # `see <log> for more information`.
637
- # Note: it may sometimes say
638
- # `see build.log or root.log for more information`, but in
639
- # that situation, we only want to handle build.log (for now),
640
- # which means accepting only the first match for the regular
641
- # expression.
642
- with artifacts_zip.open(zipinfo.filename) as task_failed_log:
643
- contents = task_failed_log.read().decode("utf-8")
644
- match = FAILURE_LOG_REGEX.search(contents)
645
- if not match:
646
- LOG.error(
647
- "task_failed.log does not indicate which log contains the failure."
648
- )
649
- raise SyntaxError(
650
- "task_failed.log does not indicate which log contains the failure."
651
- )
652
- failure_log_name = match.group(1)
653
-
654
- failed_arches[architecture] = PurePath(path.parent, failure_log_name)
655
-
656
- if not failed_arches:
657
- # No failed task found in the sub-tasks.
658
- raise FileNotFoundError("Could not detect failed architecture.")
659
-
660
- # We only want to handle one arch, so we'll check them in order of
661
- # "most to least likely for the maintainer to have access to hardware"
662
- # This means: x86_64 > aarch64 > riscv > ppc64le > s390x
663
- if "x86_64" in failed_arches:
664
- failed_arch = "x86_64"
665
- elif "aarch64" in failed_arches:
666
- failed_arch = "aarch64"
667
- elif "riscv" in failed_arches:
668
- failed_arch = "riscv"
669
- elif "ppc64le" in failed_arches:
670
- failed_arch = "ppc64le"
671
- elif "s390x" in failed_arches:
672
- failed_arch = "s390x"
673
- elif "noarch" in failed_arches:
674
- # May have failed during BuildSRPMFromSCM phase
675
- failed_arch = "noarch"
676
- elif "toplevel" in failed_arches:
677
- # Probably a Koji-specific error, not a build error
678
- failed_arch = "toplevel"
679
- else:
680
- # We have one or more architectures that we don't know about? Just
681
- # pick the first alphabetically.
682
- failed_arch = sorted(list(failed_arches.keys()))[0]
683
-
684
- LOG.debug("Failed architecture: %s", failed_arch)
685
-
686
- log_path = failed_arches[failed_arch].as_posix()
687
-
688
- log_url = f"{SERVER_CONFIG.gitlab.api_url}/projects/{job.project_id}/jobs/{job.id}/artifacts/{log_path}" # pylint: disable=line-too-long
689
- LOG.debug("Returning contents of %s", log_url)
690
-
691
- # Return the log as a file-like object with .read() function
692
- return log_url, artifacts_zip.open(log_path)
693
-
694
-
695
- async def check_artifacts_file_size(
696
- http: aiohttp.ClientSession,
697
- job: gitlab.v4.objects.ProjectJob,
698
- ):
699
- """Method to determine if the artifacts are too large to process"""
700
- # First, make sure that the artifacts are of a reasonable size. The
701
- # zipped artifact collection will be stored in memory below. The
702
- # python-gitlab library doesn't expose a way to check this value directly,
703
- # so we need to interact with directly with the headers.
704
- artifacts_url = f"{SERVER_CONFIG.gitlab.api_url}/projects/{job.project_id}/jobs/{job.id}/artifacts" # pylint: disable=line-too-long
705
- LOG.debug("checking artifact URL %s", artifacts_url)
706
- try:
707
- head_response = await http.head(
708
- artifacts_url,
709
- allow_redirects=True,
710
- headers={"Authorization": f"Bearer {SERVER_CONFIG.gitlab.api_token}"},
711
- timeout=5,
712
- raise_for_status=True,
713
- )
714
- except aiohttp.ClientResponseError as ex:
715
- raise HTTPException(
716
- status_code=400,
717
- detail=f"Unable to check artifact URL: [{ex.status}] {ex.message}",
718
- ) from ex
719
- content_length = int(head_response.headers.get("content-length"))
720
- LOG.debug(
721
- "URL: %s, content-length: %d, max length: %d",
722
- artifacts_url,
723
- content_length,
724
- SERVER_CONFIG.gitlab.max_artifact_size,
725
- )
726
- return content_length <= SERVER_CONFIG.gitlab.max_artifact_size
341
+ # No return value or body is required for a webhook.
342
+ # 204: No Content
343
+ return BasicResponse(status_code=204)
727
344
 
728
345
 
729
- async def comment_on_mr( # pylint: disable=too-many-arguments disable=too-many-positional-arguments
730
- forge: Forge,
731
- project: gitlab.v4.objects.Project,
732
- merge_request_iid: int,
733
- job: gitlab.v4.objects.ProjectJob,
734
- log_url: str,
735
- response: StagedResponse,
736
- metrics_id: int,
346
+ async def schedule_emoji_collection_for_mr(
347
+ forge: Forge, project_id: int, mr_iid: int, background_tasks: BackgroundTasks
737
348
  ):
738
- """Add the Log Detective response as a comment to the merge request"""
739
- LOG.debug(
740
- "Primary Explanation for %s MR %d: %s",
741
- project.name,
742
- merge_request_iid,
743
- response.explanation.text,
744
- )
745
-
746
- # First, we'll see if there's an existing comment on this Merge Request
747
- # and wrap it in <details></details> to reduce noise.
748
- await suppress_latest_comment(forge, project, merge_request_iid)
349
+ """Background task to update the database on emoji reactions"""
749
350
 
750
- # Get the formatted short comment.
751
- short_comment = await generate_mr_comment(job, log_url, response, full=False)
351
+ key = (forge, project_id, mr_iid)
752
352
 
753
- # Look up the merge request
754
- merge_request = await asyncio.to_thread(
755
- project.mergerequests.get, merge_request_iid
756
- )
757
-
758
- # Submit a new comment to the Merge Request using the Gitlab API
759
- discussion = await asyncio.to_thread(
760
- merge_request.discussions.create, {"body": short_comment}
761
- )
762
-
763
- # Get the ID of the first note
764
- note_id = discussion.attributes["notes"][0]["id"]
765
- note = discussion.notes.get(note_id)
766
-
767
- # Update the comment with the full details
768
- # We do this in a second step so we don't bombard the user's email
769
- # notifications with a massive message. Gitlab doesn't send email for
770
- # comment edits.
771
- full_comment = await generate_mr_comment(job, log_url, response, full=True)
772
- note.body = full_comment
353
+ # FIXME: Look up the connection from the Forge # pylint: disable=fixme
354
+ gitlab_conn = SERVER_CONFIG.gitlab.instances[forge.value]
773
355
 
774
- # Pause for five seconds before sending the snippet data, otherwise
775
- # Gitlab may bundle the edited message together with the creation
776
- # message in email.
777
- await asyncio.sleep(5)
778
- await asyncio.to_thread(note.save)
356
+ LOG.debug("Looking up emojis for %s, %d, %d", forge, project_id, mr_iid)
357
+ await collect_emojis_for_mr(project_id, mr_iid, gitlab_conn)
779
358
 
780
- # Save the new comment to the database
781
- try:
782
- metrics = AnalyzeRequestMetrics.get_metric_by_id(metrics_id)
783
- Comments.create(
359
+ # Check whether we've been asked to re-schedule this lookup because
360
+ # another request came in while it was processing.
361
+ if emoji_lookup[key]:
362
+ # The value is Truthy, which tells us to re-schedule
363
+ # Reset the boolean value to indicate that we're underway again.
364
+ emoji_lookup[key] = False
365
+ background_tasks.add_task(
366
+ schedule_emoji_collection_for_mr,
784
367
  forge,
785
- project.id,
786
- merge_request_iid,
787
- job.id,
788
- discussion.id,
789
- metrics,
368
+ project_id,
369
+ mr_iid,
370
+ background_tasks,
790
371
  )
791
- except sqlalchemy.exc.IntegrityError:
792
- # We most likely attempted to save a new comment for the same
793
- # build job. This is somewhat common during development when we're
794
- # submitting requests manually. It shouldn't really happen in
795
- # production.
796
- if not SERVER_CONFIG.general.devmode:
797
- raise
798
-
799
-
800
- async def suppress_latest_comment(
801
- gitlab_instance: str,
802
- project: gitlab.v4.objects.Project,
803
- merge_request_iid: int,
804
- ) -> None:
805
- """Look up the latest comment on this Merge Request, if any, and wrap it
806
- in a <details></details> block with a comment indicating that it has been
807
- superseded by a new push."""
808
-
809
- # Ask the database for the last known comment for this MR
810
- previous_comment = Comments.get_latest_comment(
811
- gitlab_instance, project.id, merge_request_iid
812
- )
813
-
814
- if previous_comment is None:
815
- # No existing comment, so nothing to do.
816
372
  return
817
373
 
818
- # Retrieve its content from the Gitlab API
819
-
820
- # Look up the merge request
821
- merge_request = await asyncio.to_thread(
822
- project.mergerequests.get, merge_request_iid
823
- )
824
-
825
- # Find the discussion matching the latest comment ID
826
- discussion = await asyncio.to_thread(
827
- merge_request.discussions.get, previous_comment.comment_id
828
- )
829
-
830
- # Get the ID of the first note
831
- note_id = discussion.attributes["notes"][0]["id"]
832
- note = discussion.notes.get(note_id)
833
-
834
- # Wrap the note in <details>, indicating why.
835
- note.body = (
836
- "This comment has been superseded by a newer "
837
- f"Log Detective analysis.\n<details>\n{note.body}\n</details>"
838
- )
839
- await asyncio.to_thread(note.save)
840
-
841
-
842
- async def generate_mr_comment(
843
- job: gitlab.v4.objects.ProjectJob,
844
- log_url: str,
845
- response: StagedResponse,
846
- full: bool = True,
847
- ) -> str:
848
- """Use a template to generate a comment string to submit to Gitlab"""
849
-
850
- # Locate and load the comment template
851
- script_path = Path(__file__).resolve().parent
852
- template_path = Path(script_path, "templates")
853
- jinja_env = jinja2.Environment(loader=jinja2.FileSystemLoader(template_path))
854
-
855
- if full:
856
- tpl = jinja_env.get_template("gitlab_full_comment.md.j2")
857
- else:
858
- tpl = jinja_env.get_template("gitlab_short_comment.md.j2")
859
-
860
- artifacts_url = f"{job.project_url}/-/jobs/{job.id}/artifacts/download"
861
-
862
- if response.response_certainty >= 90:
863
- emoji_face = ":slight_smile:"
864
- elif response.response_certainty >= 70:
865
- emoji_face = ":neutral_face:"
866
- else:
867
- emoji_face = ":frowning2:"
868
-
869
- # Generate the comment from the template
870
- content = tpl.render(
871
- package=job.project_name,
872
- explanation=response.explanation.text,
873
- certainty=f"{response.response_certainty:.2f}",
874
- emoji_face=emoji_face,
875
- snippets=response.snippets,
876
- log_url=log_url,
877
- artifacts_url=artifacts_url,
878
- )
879
-
880
- return content
374
+ # We're all done, so clear this entry out of the lookup
375
+ del emoji_lookup[key]
881
376
 
882
377
 
883
378
  def _svg_figure_response(fig: matplotlib.figure.Figure):
@@ -979,3 +474,41 @@ async def get_metrics(
979
474
  handler.__doc__ = descriptions[plot]
980
475
 
981
476
  return await handler()
477
+
478
+
479
+ async def collect_emoji_task():
480
+ """Collect emoji feedback.
481
+ Query only comments created in the last year.
482
+ """
483
+
484
+ for instance in SERVER_CONFIG.gitlab.instances.values():
485
+ LOG.info(
486
+ "Collect emoji feedback for %s started at %s",
487
+ instance.url,
488
+ datetime.datetime.now(datetime.timezone.utc),
489
+ )
490
+ await collect_emojis(instance.get_connection(), TimePeriod(weeks="54"))
491
+ LOG.info(
492
+ "Collect emoji feedback finished at %s",
493
+ datetime.datetime.now(datetime.timezone.utc),
494
+ )
495
+
496
+
497
+ async def schedule_collect_emojis_task():
498
+ """Schedule the collect_emojis_task to run every day at midnight"""
499
+ while True:
500
+ now = datetime.datetime.now(datetime.timezone.utc)
501
+ midnight = datetime.datetime.combine(
502
+ now.date() + datetime.timedelta(days=1),
503
+ datetime.time(0, 0),
504
+ datetime.timezone.utc,
505
+ )
506
+ seconds_until_run = (midnight - now).total_seconds()
507
+
508
+ LOG.info("Collect emojis in %d seconds", seconds_until_run)
509
+ await asyncio.sleep(seconds_until_run)
510
+
511
+ try:
512
+ await collect_emoji_task()
513
+ except Exception as e: # pylint: disable=broad-exception-caught
514
+ LOG.error("Error in collect_emoji_task: %s", e)