@@ -410,7 +410,7 @@ async def _generic_norm_upsert(
410410 INSERT INTO "{ table } " (
411411 { ", " .join (ins_col )}
412412 ) VALUES (
413- { ", " .join (["$" + str (i + 1 ) for i in range (len (ins_col ))])}
413+ { ", " .join (["$" + str (i + 1 ) for i in range (len (ins_col ))])}
414414 ) RETURNING { ", " .join (sel_cols )}
415415 """ ,
416416 * ins_args ,
@@ -495,6 +495,108 @@ async def get_pending_refreshes(self, limit: int = 50) -> list[dict[str, Any]]:
495495 )
496496 return [dict (row ) for row in rows ]
497497
498+ async def refresh_mr_payload_from_api (
499+ self , merge_request_ref_id : int , api_data : dict [str , Any ]
500+ ) -> MergeRequestInfos :
501+ """Update stored MR payload with fresh state from GitLab API.
502+
503+ Syncs state, title, draft, merge status, branches, and pipeline ID.
504+ Assignees/reviewers are not updated (API response lacks email field required by GLUser).
505+ """
506+ connection : asyncpg .Connection
507+ async with await database .acquire () as connection :
508+ async with connection .transaction ():
509+ row = await connection .fetchrow (
510+ """SELECT merge_request_ref_id, merge_request_payload,
511+ merge_request_extra_state, head_pipeline_id
512+ FROM merge_request_ref
513+ WHERE merge_request_ref_id = $1
514+ FOR UPDATE""" ,
515+ merge_request_ref_id ,
516+ )
517+ assert row is not None
518+
519+ payload = row ["merge_request_payload" ]
520+ oa = payload .get ("object_attributes" , {})
521+
522+ for field in (
523+ "state" ,
524+ "title" ,
525+ "draft" ,
526+ "detailed_merge_status" ,
527+ "source_branch" ,
528+ "target_branch" ,
529+ ):
530+ if field in api_data :
531+ oa [field ] = api_data [field ]
532+
533+ # GitLab REST API returns updated_at as ISO 8601 ("...Z");
534+ # webhook payloads use "YYYY-MM-DD HH:MM:SS UTC". Normalize
535+ # to webhook format so downstream fromisoformat parsing
536+ # (with the " UTC" -> "+00:00" replace) keeps working.
537+ # Defensive: if GitLab ever returns a naive datetime (no
538+ # offset), assume UTC rather than letting astimezone() apply
539+ # the host's local TZ. If parsing fails outright, log and
540+ # keep the stored value — never raise here, otherwise the
541+ # whole pending_mr_refresh row gets stuck retrying forever.
542+ if "updated_at" in api_data and api_data ["updated_at" ]:
543+ raw = api_data ["updated_at" ]
544+ try :
545+ parsed = datetime .datetime .fromisoformat (raw .replace ("Z" , "+00:00" ))
546+ if parsed .tzinfo is None :
547+ parsed = parsed .replace (tzinfo = datetime .UTC )
548+ oa ["updated_at" ] = parsed .astimezone (datetime .UTC ).strftime ("%Y-%m-%d %H:%M:%S UTC" )
549+ except (ValueError , TypeError ) as exc :
550+ log .warning (
551+ "could not parse api updated_at, keeping stored value" ,
552+ merge_request_ref_id = merge_request_ref_id ,
553+ raw = raw ,
554+ error = str (exc ),
555+ )
556+
557+ if "draft" in api_data :
558+ oa ["work_in_progress" ] = api_data ["draft" ]
559+
560+ # Synthesize `action` from state so cards/render.py picks the
561+ # right icon (CodeTextOff for close, Merge for merge). The
562+ # renderer keys off action, not state, so we MUST set it.
563+ # Only mutate on terminal/reopen transitions; otherwise keep
564+ # the webhook-recorded action to avoid masking real events.
565+ api_state = api_data .get ("state" )
566+ if api_state == "merged" and oa .get ("action" ) != "merge" :
567+ oa ["action" ] = "merge"
568+ elif api_state == "closed" and oa .get ("action" ) != "close" :
569+ oa ["action" ] = "close"
570+ elif api_state == "opened" and oa .get ("action" ) in ("close" , "merge" ):
571+ oa ["action" ] = "reopen"
572+
573+ head_pipeline_id = row ["head_pipeline_id" ]
574+ api_pipeline = api_data .get ("head_pipeline" )
575+ if api_pipeline and api_pipeline .get ("id" ):
576+ oa ["head_pipeline_id" ] = api_pipeline ["id" ]
577+ head_pipeline_id = api_pipeline ["id" ]
578+
579+ payload ["object_attributes" ] = oa
580+
581+ await connection .execute (
582+ """UPDATE merge_request_ref
583+ SET merge_request_payload = $1, head_pipeline_id = $2
584+ WHERE merge_request_ref_id = $3""" ,
585+ payload ,
586+ head_pipeline_id ,
587+ merge_request_ref_id ,
588+ )
589+
590+ # extra_state is read from the pre-update `row` snapshot. Safe today
591+ # because this function does not mutate extra_state; if that ever
592+ # changes, re-read it after the UPDATE or RETURNING it.
593+ return MergeRequestInfos (
594+ merge_request_ref_id = merge_request_ref_id ,
595+ merge_request_payload = payload ,
596+ merge_request_extra_state = row ["merge_request_extra_state" ],
597+ head_pipeline_id = head_pipeline_id ,
598+ )
599+
498600 async def delete_pending_refresh (self , merge_request_ref_id : int ) -> None :
499601 """Delete a pending refresh after processing."""
500602 connection : asyncpg .Connection
0 commit comments