
    <iZ              	         U d Z ddlmZ ddlZddlZddlZddlZddlZddlZddl	Z	ddl
Z
ddlmZmZ ddlmZ ddlmZmZ  ee      j)                         j*                  Z eej.                  j1                  d eej*                                    j)                         Z ee      e	j6                  vr"e	j6                  j9                  d ee              ee      e	j6                  vr"e	j6                  j9                  d ee             ddlmZmZ ej.                  j1                  d	d
      Z edz  dz  dz  Z!edz  dz  dz  Z"edz  dz  Z# e$h d      Z%de&d<    e$h d      Z'de&d<    e$dh      Z(de&d<   dZ) ejT                  d      Z+e+jX                  si ejZ                  e	j\                        Z/e/ja                   ejb                  d             e+je                  e/       e+jg                  ejh                         e G d d             Z5e G d d             Z6e G d d              Z7d;d!Z8d"d"d#d<d$Z9d%d&d=d'Z:d>d?d(Z;d@d)Z<dd*dAd+Z=dBd,Z>dCd-Z?dDd.Z@dEd/ZAdFd0ZBdGd1ZCdHd2aDdId3ZEe:e;dd4	 	 	 	 	 	 	 	 	 	 	 dJd5ZFdKd6ZGe fe:e;dddde
j                  d7	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 dLd8ZHd>dMd9ZIeJd:k(  r e	j                   eI              yy)Nu
  auto_merge_controller.py — Auto-merge controller (task-2444).

Goal (회장 명시):

    "회장 승인 없이도 안전한 PR은 자동 merge되고, 위험한 PR은 GitHub
     ruleset과 controller 양쪽에서 차단된다."
    "봇은 ruleset을 우회하지 않는다. GitHub이 허용한 PR만 자동 처리한다."

The controller polls open PRs targeting ``main`` and merges them only when
**all** of the following GitHub-side preconditions hold:

  1. PR ``base.ref == "main"``
  2. No ``memory/events/<task-id>.cancelled`` marker
  3. All 8 required check-runs are present and ``conclusion == "success"``
     ({ci/guard, guard, cancel-kill-switch, qc-check, hidden-path-audit,
       lock-in-check, merge-safety-check, gemini-review-gate})
  4. ``mergeable_state`` is **not** in {blocked, behind, dirty, unstable}
  5. ``gemini-review-gate`` conclusion is ``success`` (re-asserted)
  6. Zero unresolved review threads (GraphQL ``reviewThreads.isResolved``)
  7. PR is up-to-date with main (``mergeable_state != "behind"``)

Only when **every** condition is satisfied do we invoke
``gh pr merge <num> --auto --merge --delete-branch``. The controller never
uses ``--admin``, never calls ``git push origin main``, and never tries to
merge a PR whose ``mergeable_state`` is ``BLOCKED``.

The controller is **idempotent and safe to run on a cron**: every cycle is
protected by a process-wide ``FileLock``; main-HEAD is recorded in
``memory/logs/auto-merge-audit.jsonl`` before/after each merge so we can
prove nothing was force-pushed.
    )annotationsN)	dataclassfield)Path)AnyCallableWORKSPACE_ROOT)FileLockLockTimeoutREPOzJonghyukJeon/dev_workspacememorycachezauto_merge_controller.locklogszauto-merge-audit.jsonlevents>   ci/guardqc-checklock-in-checkhidden-path-auditcancel-kill-switchmerge-safety-checkguardgemini-review-gatezfrozenset[str]REQUIRED_CHECKS>   dirtydraftbehindblockedunknownunstableBLOCKED_MERGE_STATESz--adminFORBIDDEN_FLAGS)gitpushauto_merge_controllerz1%(asctime)s [%(levelname)s] %(name)s: %(message)sc                  4    e Zd ZU dZded<   ded<   dZded<   y)	SkipDecisionz?Why a PR was skipped this cycle (still open, no merge attempt).int	pr_numberstrreasonN
str | Nonelabel)__name__
__module____qualname____doc____annotations__r,        N/home/jay/workspace/.worktrees/task-2444-dev2/scripts/auto_merge_controller.pyr&   r&   u   s    INKE:r3   r&   c                  d    e Zd ZU dZded<   ded<   ded<   ded<   dZd	ed
<   dZded<   dZd	ed<   y)MergeDecisionzBA PR cleared all gates and the controller invoked ``gh pr merge``.r'   r(   r)   head_branchhead_shamain_head_beforeNr+   main_head_afterFboolmerged	merged_at)r-   r.   r/   r0   r1   r:   r<   r=   r2   r3   r4   r6   r6   ~   s9    LNM"&OZ&FD Iz r3   r6   c                      e Zd ZU dZded<   ded<   dZded<   dZd	ed
<    ee      Z	ded<    ee      Z
ded<    ee      Zded<    ee      Zded<   y)CycleResultz9Result of one controller cycle. Useful for tests + audit.r)   repofloat
started_atNzfloat | Nonefinished_atr+   main_head_before_cycle)default_factoryzlist[SkipDecision]skippedzlist[MergeDecision]r<   z	list[int]cancelled_closed	list[str]errors)r-   r.   r/   r0   r1   rC   rD   r   listrF   r<   rG   rI   r2   r3   r4   r?   r?      sd    C
I $K$)-J-"'"=G="'"=F="'"=i=d3FI3r3   r?   c                L   | D cg c]  }|t         v s| }}|rt        d|       t        |       dk\  ri| d   t        d   k(  rY| d   t        d   k(  rIdj	                  |       }t        j                  d|      st        j                  d|      rt        d      y	y	y	y	c c}w )
z:Hard-block dangerous commands BEFORE subprocess execution.z*[FORBIDDEN] forbidden flag(s) in command:    r       z\borigin\s+main\bz\bHEAD:main\bz.[FORBIDDEN] direct push to main is not allowedN)r!   RuntimeErrorlenFORBIDDEN_GIT_PUSHjoinresearch)cmdfbadjoineds       r4   _enforce_forbiddenrY      s    
2Q/11
2C
2
GuMNN
3x1}Q#5a#88SVGYZ[G\=\#99)62bii@PRX6YOPP 7Z >]8}	 3s
   B!B!T)checkcapturec          
     B   t        |        t        j                  ddj                  |              t	        j
                  | |dd      }|rS|j                  dk7  rDt        d|j                   ddj                  |        d	|j                   d
|j                         |S )z>Run a subprocess with the forbidden-flag guard always engaged.zrun_cmd: %srN   TF)capture_outputtextrZ   r   zcommand failed (z): z
stdout=z
stderr=)
rY   LOGGERdebugrR   
subprocessrun
returncoderO   stdoutstderr)rU   rZ   r[   procs       r4   run_cmdrg      s    s
LL.>>	D A%t/s388C=/ Bkk])DKK=:
 	
 Kr3   GET)methodc                   ddd|| g}t        |d      }|j                  j                         syt        j                  |j                        S )z.Call ``gh api <path>`` and return parsed JSON.ghapiz-XTrZ   N)rg   rd   stripjsonloads)pathri   rU   rf   s       r4   gh_apirr      sD    fd
+C3d#D;;::dkk""r3   c                   ddddd|  g}|xs i j                         D ]  \  }}|j                  d| d| g        t        |d	      }|j                  j	                         rt        j                  |j                        S d
S )zACall ``gh api graphql`` for richer queries (review threads etc.).rk   rl   graphqlz-fzquery=z-F=Trm   N)itemsextendrg   rd   rn   ro   rp   )query	variablesrU   kvrf   s         r4   gh_api_graphqlr|      s    	46%)9
:Cb'') '1

DQCq*%&'3d#D&*kk&7&7&94::dkk"CtCr3   c                0    t        d|  d      }|d   d   S )z@Return the current HEAD sha of ``main`` per the GitHub REST API./repos/z/branches/maincommitsharr   )r@   datas     r4   get_main_headr      s$    GD601D>%  r3   extrac                  t        |      }t        j                  j                  dd       t	        j                         t	        j
                  dt	        j                               | ||d}|r|j                  |       t        j                  d      5 }|j                  t        j                  |d      dz          ddd       |S # 1 sw Y   |S xY w)	z9Append a main-HEAD audit entry. Returns the recorded sha.T)parentsexist_okz%Y-%m-%dT%H:%M:%SZ)	timestampisostager@   	main_heada)	sort_keys
N)r   	AUDIT_LOGparentmkdirtimestrftimegmtimeupdateopenwritero   dumps)r   r@   r   r   entryrV   s         r4   record_main_headr      s    

C4$7YY[}}14;;=AE U		 :	

5D1D89:J:Js   *C		Cc                `    t        j                  d| xs d      }|r|j                  d      S dS )zEExtract ``task-N`` from a head branch (``task/task-2444-dev2`` etc.).ztask-\d+ r   N)rS   rT   group)branchms     r4   task_id_from_branchr      s+    
		+v|,A1771:$$r3   c                    t         |  dz  S )Nz
.cancelled)
EVENTS_DIR)task_ids    r4   cancelled_marker_pathr      s    7):...r3   c                   i }| xs g D ]0  }|j                  d      }|t        vr|j                  d      ||<   2 t        t              |j                         z
  }|j	                         D ch c]  \  }}|dk7  s| }}}||fS c c}}w )zReturn (missing, non_success) for the 8 required checks.

    A check is ``non_success`` if it appears with conclusion != ``success``
    (or if its conclusion is None, i.e. still in progress).
    name
conclusionsuccess)getr   setkeysrv   )
check_runspresent_latestcrr   missingr   non_successs          r4   required_check_stater      s     -/NB 4vvf~&  "vvl3t4 /"^%8%8%::G%3%9%9%;!zzY?VK  Ks   /B=Bc                    | sy| j                  di       j                  di       j                  di       j                  di       j                  dg       }t        d |D              S )z(True if any review thread is unresolved.Fr   
repositorypullRequestreviewThreadsnodesc              3  B   K   | ]  }|j                  d d         yw)
isResolvedTNr   ).0ns     r4   	<genexpr>z)has_unresolved_threads.<locals>.<genexpr>  s     <155t,,<s   )r   any)graphql_responser   s     r4   has_unresolved_threadsr     s`    VR(	\2		]B		_b	!	Wb	 
 <e<<<r3   c           
         	 t        dddt        |       d|d|gd       y	# t        $ r!}t        j	                  d| |       Y d	}~y	d	}~ww xY w)
z7Attach a label to the PR (e.g. ``auto-merge-blocked``).rk   preditz--add-label--repoFrm   z#label_blocked failed for PR #%d: %sN)rg   r)   rO   r_   warning)pr_numr,   r@   excs       r4   label_blockedr   $  s^    KF	 	
  K<fcJJKs   ! 	AAAc                    | d   }| d   d   }t         j                  d||       t        dddt        |      dd	|d
dg	d       y)z@Close a PR whose task has a cancelled marker. Branch is deleted.numberheadrefz)[cancel-close] closing PR #%d (branch=%s)rk   r   close--delete-branchr   z	--commentz=[auto-merge-controller] cancelled marker detected, PR closed.Frm   N)r_   inforg   r)   )r   r@   r   r   s       r4   handle_cancelled_prr   8  s[    \FZF
KK;VVLKK
	
 r3   c           	     D    dddt        |       dddd|g	}t        |d	      S )
zInvoke ``gh pr merge --auto --merge --delete-branch``.

    The forbidden-flag guard inside ``run_cmd`` will refuse any caller that
    tries to inject ``--admin`` or push directly to main.
    rk   r   mergez--autoz--merger   r   Frm   )r)   rg   )r   r@   rU   s      r4   
safe_merger   M  s9     	F
C 3e$$r3   c                    t        d| d|        }t        j                  ddd| d| gdd      }t        |j	                  d            |j	                  d	      |j
                  d
k7  |j	                  d      dS )zVerify GitHub's view of the merge after we invoked it.

    Returns a dict with ``merged``, ``merged_at``, ``branch_deleted`` so the
    caller (and audit log) can store the truth.
    r~   /pulls/rk   rl   z
/branches/T)r]   r^   r<   r=   r   merge_commit_sha)r<   r=   branch_deletedr   )rr   ra   rb   r;   r   rc   )r   expected_branchr@   r   branch_procs        r4   
post_checkr   a  s     
'$wvh/	0B..	uvZ/@ABK rvvh'(VVK(%00A5FF#56	 r3   )rl   rt   cancelled_marker_existsc               >   |d }| d   }| j                  di       xs i }|j                  dd      }|j                  dd      }| j                  d      xs i j                  dd      }	|	d	k7  rt        |d
|	d      S t        |      }
|
r ||
      rt        |d|
 dd      S  |d| d| d      }t        |j                  dg             \  }}|s|rt	        d |j                  dg       D        d      }||dk7  r	d}d|d}nE|r|rd}dt        |       dt        |       }n$|rd}dt        |       d}nd}dt        |       }t        |||      S  |d| d|       }|j                  d      xs dj                         }|t        v rt        |d|d      S t	        d |j                  dg       D        d      }|dk7  rt        |d|dd      S d }|j                  d!d"      \  }} |||||d#      }t        |      rt        |d$d      S y)%u,  Return a SkipDecision (with reason/label) or None if PR is mergeable.

    Pure with respect to side effects — callers handle close/label/merge.
    Dependency-injected ``api`` / ``graphql`` / ``cancelled_marker_exists``
    keep this fully testable without touching the network or filesystem.
    Nc                4    t        |       j                         S Nr   existstids    r4   <lambda>zevaluate_pr.<locals>.<lambda>  s    .CC.H.O.O.Q r3   r   r   r   r   r   basemainz	base.ref=z (not main)zcancelled marker present ())r,   r~   z	/commits/z/check-runsr   c              3  f   K   | ])  }|j                  d       dk(  r|j                  d       + ywr   r   r   Nr   r   r   s     r4   r   zevaluate_pr.<locals>.<genexpr>  s2      66&>%99 |$   /1r   zgemini-blockedz!gemini-review-gate not success (=zauto-merge-blockedzmissing=z non_success=z (in_progress or not started)znon_success=r   mergeable_statezmergeable_state=c              3  f   K   | ])  }|j                  d       dk(  r|j                  d       + ywr   r   r   s     r4   r   zevaluate_pr.<locals>.<genexpr>  s2      	
vvf~!55 FF< 	
r   z
      query($owner:String!,$name:String!,$number:Int!){
        repository(owner:$owner,name:$name){
          pullRequest(number:$number){
            reviewThreads(first:100){ nodes { isResolved } }
          }
        }
      }
    /rM   )ownerr   r   zunresolved conversations)
r   r&   r   r   nextsortedlowerr    splitr   )r   r@   rl   rt   r   r   r   r   r   base_refr   
cr_payloadr   r   gemini_stater,   r*   pr_fullstaterx   r   r   threadss                          r4   evaluate_prr   z  s    &"Q\F66&"#DXXeR F
((5"
Cv$"))%4H 6Fi|;$GHH "&)G*73F&@	$KSWXX wtfIcU+>?J/
|R0PQG[+$..r:
 
 #	(A$E88HJF(Ew0f[>Q=RSFEw00MNF(E#F;$7#89FFF%88 GD612G[[*+1r88:E$$F&6ui$@H\]] 	
 nn\26	

 	L y //?qA"
 	
E **S!$KE4eudfMNGg&F$>FZ[[ r3   c                (    t        d|  d      xs g S )Nr~   z/pulls?state=open&base=mainr   r@   s    r4   list_open_prsr     s    GD6!<=>D"Dr3   )rl   rt   list_prssafe_merge_fnhead_recorderr   nowc          
         |xs t         }|xs t        }|xs  fd}t          |             }	  |d      |_        	  |       }
|
D ]  }|d   }|j                  d      xs i j                  d	d
      }t        |      }|r6 |xs d |      r)	 t        |        |j                  j                  |       q	 t        | |||      }|tt        j                  d|j                  |j                          |j"                  r!t%        |j                  |j"                          |j&                  j                  |       |d   }	  |d| ||d	   d      }t)        ||d	   |d   |      }	  ||       }|j*                  dk7  rF|j
                  j                  d| d|j*                   d|j,                  j/                                 	  |d| d|i      }||_        	 t5        ||d	          }t7        |d         |_        |d    |_        |j8                  j                  |         |       |_        |S # t        $ r)}	|j
                  j                  d|	        Y d}	~	.d}	~	ww xY w# t        $ r6}	|j
                  j                  d|	         |       |_        |cY d}	~	S d}	~	ww xY w# t        $ r,}	|j
                  j                  d| d|	        Y d}	~	-d}	~	ww xY w# t        $ r,}	|j
                  j                  d| d|	        Y d}	~	d}	~	ww xY w# t        $ r.}	|j
                  j                  d| d|	        d
}Y d}	~	d}	~	ww xY w# t0        $ r,}	|j
                  j                  d| d|	        Y d}	~	Gd}	~	ww xY w# t        $ r,}	|j
                  j                  d| d|	        Y d}	~	d}	~	ww xY w# t        $ r,}	|j
                  j                  d!| d|	        Y d}	~	d}	~	ww xY w)"z2Run one full cycle. Dependency-injected for tests.c                    t        | fi |S r   )r   )r   kwr@   s     r4   r   z"process_open_prs.<locals>.<lambda>  s    :J5RV:]Z\:] r3   )r@   rB   zbefore-cyclezhead-record before-cycle: Nzlist_open_prs: r   r   r   r   c                4    t        |       j                         S r   r   r   s    r4   r   z"process_open_prs.<locals>.<lambda>	  s    @UVY@Z@a@a@c r3   zclose cancelled #z: )r@   rl   rt   r   z
evaluate #u   [skip] PR #%d — %szbefore-merge-pr-)r   r   r   zhead-record before-merge #r   )r(   r7   r8   r9   r   zsafe_merge #z rc=z stderr=zsafe_merge guarded #zafter-merge-pr-r   zhead-record after-merge #r<   r=   zpost_check #)r   r   r?   rD   	ExceptionrI   appendrC   r   r   r   rG   r   r_   r   r(   r*   r,   r   rF   r6   rc   re   rn   rO   r:   r   r;   r<   r=   )r@   rl   rt   r   r   r   r   r   resultr   prsr   r   r   r   decisionr   beforemerge_decisionrf   afterpcs   `                     r4   process_open_prsr    s     (=H!/ZM!^&]Mdsu5FA(5n(E%tn  J-H&&.&B++E26%f-d/d4cfmnJ#B-''..v6 
	"(?H KK.0B0BHOOT~~h00(..$GNN!!(+ &z	""6(+&DQVK3XF 'U%[#	
		 .D!#$$"6($t.?xHYHYH[G\]	N!!&*4.E .3N*	AFDK6B$(H$6N!')+N$ 	^,UJ-X FMo  A9#?@@A
  se45 U  J$$'83%%HIIJ  	MM  :fXRu!=>	"  	MM  #=fXRu!MNF	"  	MM  #7xr#!GH	  	NMM  #<VHBse!LMM	N  	AMM  <xr#!?@@	As   I   I5 'J7<K/L'1AM!N'-O 	I2	I--I25	J4>+J/)J4/J47	K, !K''K,/	L$8!LL$'	M0#MM!	N*!NN	O"!O		O	P!PPc           	        t        j                  d      }|j                  dt        d       |j                  dt        dd	       |j                  d
dd       |j                  dd       |j                  |       }|j                  r#t        j                  t        j                         |j                  rdd}|a	 t        t        |j                        5  t!        |j"                        }d d d        t        j)                  dt+        j,                        t+        |j.                        t+        |j0                        t+        |j2                               |j2                  r(|j2                  D ]  }t        j5                  d|        yy# 1 sw Y   xY w# t$        $ r }t        j'                  d|       Y d }~yd }~ww xY w)Nz!Auto-merge controller (task-2444))descriptionr   zGitHub repo (owner/name))defaulthelpz--lock-timeoutg      $@z'Seconds to wait for the controller lock)typer  r  z	--dry-run
store_truez9Evaluate PRs but do not perform merge/close/label actions)actionr  z	--verbose)r  c                b    t         j                  d| |       t        j                  g ddd      S )Nz&[dry-run] would merge PR #%d (repo=%s)r   r   )argsrc   rd   re   )r_   r   ra   CompletedProcess)r   r@   s     r4   	_no_mergezmain.<locals>._no_mergeq  s+    KK@&$O..B1RXZ[[r3   )timeoutr   zcontroller already running: %sr   u:   cycle done — merged=%d skipped=%d cancelled=%d errors=%dz  - %srM   r   r'   r@   r)   returnsubprocess.CompletedProcess)argparseArgumentParseradd_argumentREPO_DEFAULTrA   
parse_argsverboser_   setLevelloggingDEBUGdry_runr   r
   	LOCK_PATHlock_timeoutr  r@   r   r   r   rP   r<   rF   rG   rI   error)argvparserr  r  r  r   errs          r4   r   r   Z  s   $$1TUF
,=WX
6	   H  
 L9T"D||&||	\ 
i):):; 	6%4995F	6 KKDFMMFNNF##$FMM }}== 	(CLL3'	(#	6 	6 7=s0   F" F4F" FF" "	G+GG__main__)rU   rH   r  None)rU   rH   rZ   r;   r[   r;   r  r  )rq   r)   ri   r)   r  r   r   )rx   r)   ry   dict[str, Any] | Noner  r   )r@   r)   r  r)   )r   r)   r@   r)   r   r0  r  r)   )r   r)   r  r+   )r   r)   r  r   )r   list[dict[str, Any]]r  ztuple[set[str], set[str]])r   r0  r  r;   )r   r'   r,   r)   r@   r)   r  r/  )r   dict[str, Any]r@   r)   r  r/  r  )r   r'   r   r)   r@   r)   r  r2  )r   r2  r@   r)   rl   Callable[[str], Any]rt   Callable[..., Any]r   Callable[[str], bool] | Noner  zSkipDecision | None)r@   r)   r  r1  )r@   r)   rl   r3  rt   r4  r   z,Callable[[str], list[dict[str, Any]]] | Noner   z8Callable[[int, str], subprocess.CompletedProcess] | Noner   zCallable[..., str] | Noner   r5  r   zCallable[[], float]r  r?   )r+  zlist[str] | Noner  r'   )Lr0   
__future__r   r  ro   r%  osrS   ra   sysr   dataclassesr   r   pathlibr   typingr   r   __file__resolver   _HEREenvironr   r)   
_WORKSPACErq   insertauto_merge_lockr
   r   r!  r(  r   r   	frozensetr   r1   r    r!   rQ   	getLoggerr_   handlersStreamHandlerrd   handlersetFormatter	Formatter
addHandlerr$  INFOr&   r6   r?   rY   rg   rr   r|   r   r   r   r   r   r   r   r   r   r   r   r   r  r   r-   exitr2   r3   r4   <module>rM     s  @ #    	 	  
  (    	X ''"**..!13u||3DEFNNP
z?#(("HHOOAs:'u:SXXHHOOAs5z" 1 zz~~f&BC!G+.JJ	!F*-EE	("X-
"+ 	- 	# 	 (1 2 ( n  #,YK"8 8$  
		2	3#g##CJJ/GMN g
OOGLL!    	! 	! 	! 
4 
4 
4&	Q .24 $ (- #D! OS .%/ ,=&K(*%(: !'"0<@bb b 
	b
  b :b bTE
 j !'"0=ANR/3<@#yyj
j 
j  	j
 ;j Lj -j :j 
j jd/d zCHHTV r3   