From 92cfbe68917ec7a4e2a711d857b73aa3fa5bd763 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:38:07 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A7=AA=20gh=5Fgraphql=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 194c0289d978959c72bc2d57ad5d6e4362b2ed9c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:50:21 +0000 Subject: [PATCH 2/3] Fix CI coverage: add docstrings, tests, and pyproject.toml for 100% coverage --- .coverage | Bin 0 -> 53248 bytes pyproject.toml | 23 + ...de_review_normalize_output.cpython-312.pyc | Bin 0 -> 10881 bytes .../pr_review_merge_scheduler.cpython-312.pyc | Bin 0 -> 18157 bytes .../ci/opencode_review_normalize_output.py | 2 +- scripts/ci/pr_review_merge_scheduler.py | 25 +- tests/__init__.py | 0 tests/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 148 bytes ...malize_output.cpython-312-pytest-9.1.1.pyc | Bin 0 -> 58389 bytes ...rge_scheduler.cpython-312-pytest-9.1.1.pyc | Bin 0 -> 86969 bytes .../test_opencode_review_normalize_output.py | 458 +++++++++ tests/test_pr_review_merge_scheduler.py | 868 ++++++++++++++++++ 12 files changed, 1374 insertions(+), 2 deletions(-) create mode 100644 .coverage create mode 100644 pyproject.toml create mode 100644 scripts/ci/__pycache__/opencode_review_normalize_output.cpython-312.pyc create mode 100644 scripts/ci/__pycache__/pr_review_merge_scheduler.cpython-312.pyc create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-312.pyc create mode 100644 tests/__pycache__/test_opencode_review_normalize_output.cpython-312-pytest-9.1.1.pyc create mode 100644 tests/__pycache__/test_pr_review_merge_scheduler.cpython-312-pytest-9.1.1.pyc create mode 100644 tests/test_opencode_review_normalize_output.py create mode 100644 tests/test_pr_review_merge_scheduler.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..dde2c7c7aaaf340c934140906c73bb575d6fac67 GIT binary patch literal 53248 zcmeI)+lw1j90%~3Bu$!ZmRT(&tZUixqUq9XVyK|C54!4t#oF4siqL90Nlucn$xJ$z zy(n5!3)T06FMaDnLH_~~^?#^>NI`w9AQfR%Xsz*g=FH@>-IVN8yUTaTWiIEOIp=e3 znPhj~IC0c)IjdQAOLy5pWlB+1bFts3&ZXu)IiE^o_UutFalcHuU7^RsP-$pKzbHp)lu*U8t0MPhTQ@5W z?&O%>D`CZ=e6G5(w6@hMxE5;~re93cF`U_qCkD2p}VOlvR3PCO4iHUgT&>mHxR>@bLb4~i_ zRMI`y^0!M=`K2Pgq%S_QJCT{4RWF_M8;am5l?~VFtt5vUO>g&zrX!AzZa1Qr3^x+d zi<(Vl)%-3aVl!De)S3tTOd-V3ZPC$~6$GT5KG4=CGD~@2<)&30ZfkyvaX57l8VkZ; zHP5VwuHuL?RIFug>vf(Fj>=)-K{q67I>lr>vp74d>GYaFsn}ahb*AHq%*>2>A?Y`x zptgdy#849oYjTreG(6Pey5rg5Kytr9GK|q5CBr@v9HHXOC*qlfnNh_F!j+1_%I;1g zmdNbgt9E3M5aW_~?iwnD*wgO<8zzGBv)|U;2D>-!VmD$6*>}*p|Uh{+35Y+j;P{NQM`()CMuu9}hJ*b^cAQ3O`iL~xxCHosq1cpX5F?_j_Ohf4Lj@m@ zQgh-0U)Am?^uPiE2tWV=5P$##AOHafKmY;|fWXcZh^i4aA+G-;+OLXsi(arm00Izz z00bZa0SG_<0uX=z1R$_21=5k|J}vySkf)~9=*)ERHvrG%i%;hdrl?dA?WUsL)c)F* z1w^JH009U<00Izz00bZa0SG_<0ubm4q@(-P@N0lbDms%4z6FrK|DRH{FSJv=z*vC* z1Rwwb2tWV=5P$##AOHafKww;f*CH{x0YIT)wRpkyOq1J%70W(b$kz?G;gx&uPQ^Cb zu2ZNOg|=O?`Le-RN-b{JxqoATs@LQ;-40<%rR3(fl$_P3dl*?&9wusvyZkkc_j!r# zLDBYHN>%>;e_GML)J~6EFoc5u1Rwwb2tWV=5P$##AOHafK%g&hEJpYL+tLO5{jUEb zOR>&;a!bjLas7|`|Mlw&YY>0{1Rwwb2tWV=5P$##AOL|KC=gYn%B1-HU)BCq=z#?S z5P$##AOHafKmY;|fB*y_0D&DS5RFbI<^TU*QM4bmD?6}=C=vu9009U<00Izz00bZa z0SG|gp%*w5iK$69^80a_(~l2-vwrRPKX*PqX5Ie&^7ZcJ>({#7*;^~$-S{Q{LAS8p zy?Xw?uYT*Uce}SA&8o3v^5hR$5%cj%E$&|a_G{~>8-IM%Eh*i*SKsMgKX&)--#=e_ zQvUz{x}x3D)*pHhWDo)nfB*y_009U<00Izz00bZafw2YR^tFN-iNxqD0{{1aWoqoG z2nYcPKmY;|fB*y_009U<00Izzz%~>RzyIU0SrMQ009U<00Izz00bZa0SG_<0^3mFe`Gx6R{#J2 literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..1b9444d6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["scripts/ci"] + +[tool.coverage.run] +source = ["pr_review_merge_scheduler", "opencode_review_normalize_output"] +branch = false + +[tool.coverage.report] +fail_under = 100 +show_missing = true + +[tool.interrogate] +fail-under = 100 +exclude = ["tests", "setup.py"] +ignore-init-method = false +ignore-init-module = false +ignore-magic = false +ignore-semiprivate = false +ignore-private = false +ignore-property-decorators = false +ignore-module = false +ignore-nested-functions = false diff --git a/scripts/ci/__pycache__/opencode_review_normalize_output.cpython-312.pyc b/scripts/ci/__pycache__/opencode_review_normalize_output.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..549470e295a2bb405f02a3ff5766c1a1b6da7f6d GIT binary patch literal 10881 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 literal 0 HcmV?d00001 diff --git a/scripts/ci/__pycache__/pr_review_merge_scheduler.cpython-312.pyc b/scripts/ci/__pycache__/pr_review_merge_scheduler.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ff137c33e517cba1048380e36fc6f7af5f64a69 GIT binary patch literal 18157 zcmb_^TW}lKm0&l}cs3pc_@*eSMNtw#5!74O(=o+|9+oAUq%4Py41?$<2?+$~Zcq|o zFlHQYLXK4rFmZQ?pgmAmk<7VQS@xKh}S=smx?F zRl9r6?FRq~bUaf7nYVA>d+xcfbI(XHzru%2zZ+ z-J*DEfZ}Q16r)E?111{t=9qcFOrFdDL!Ooa3wc@xtmJ7Mu)&jw*+(4%4${UFbB?kD zEaa^NF2P}TK6qC10p1VK8orgUgl8?kov(st9l%w?vmTx` z@ND4Q_*!_b`nFU)Xw;zDiv)f1A2W2lk{o`XVC&KOPgf zvtvTMGr18a3mI%BzQ4*#3#aAEffWf#EF*X#MQVU9^(3vVl+Oysf}xn zhew6t_p$J>&=*ZX*~xek0V3m~lrWkExL2GUjA1MxMUx4@s3BAlw{a>uf>j0O$f$d@ zLAMM=MJaiRYu3AIK#ItFo~1#p#=8|Hxic42-lM5poA0Rd?%R=Hwlx# zUmVt5B2&`d$43VRQLE&*WHcEQjJK%NCBkTpcd|Xh`OkUw)iQ#CxXcSl(NsK}xs7yqQhgv+? z!US*-<5K5{5V_o&h{eXoipT>|#gjlzYp>JeG=(GJxKJoL0cW_F1k{T5_I552=hP0z z<0ZYR5M>&vYc(#FtPMiL_#(0K66#ZtKow4@Z#USE^nCuY776^gdeAw;%&u26n9w2>pAs_>7;Yg87b!J zX7z<$*$J6jlt2y8AgE2}K|F(KHVF)GJ_0p7138P4vtn+-7O*RJoe`uCoJhoYiA#O^OjOKLQdF#A!tN9+oK-0iKNGMh_D~4v zBNS4cq0ndoB?{zSq0r0Y;h0uq3x)VZ1d6?(P?4|*h2EuBaE^~o?ifjo3OmH{IB<|1 zxX(MJv4;oJ?B1XrO(vC=U$CwyW&vuA#z>qd2L2MtJ5K*%s$#KX?RgnA}>NDop z13UZu<9V0&`%gpi`qN^i4)!jsSfnH$jYl*&OyScGf3W=YN**$|sH9#;UD7leC^mj+ zDoukXMBg^O$EbyqrnKpjmN&p4!<#RICMG_S)buahyz-E5QM`q>UUC$-R`Y2?Qvetn zv?f|!DcoDsPpt20V}6YW7*}aMzV%T7d+G$9HrV>{!7(ur5hPXki$Zc-jC0YX1T2wH zj3?VTF$^LFXa?Yu(NUpW6j3RNb3@^1Y+MxD`vYdh5{-|IClya5JO=HB5H!Sh1{s!! z>z1nM1Wt(R1vUe=-I6i~4snlY_|F-zDbB(;6f+Qm;#5Zx0V%1tin~^rONnS)vBK~K z5$IAKCX9gzi^Gyw1Dz>0ZAucJBRGd55`(xwPtHL`8i8br`rK1@BR&()^v_q{P2Ow1 z?>RelJa6+X+Um2m`pmNnwhc?3hP-D(=IY|cUD=Jhdskcl&F%|7Q?EH|akJ?Nn4XOpL zvTD-2cB`}*v_)pp{9SXAwrELp_Kn3rf`#MUHllFtz>~eLNDl)-L5+?Bc98&rQq?k5 z9Sd;D@v)fD{)pc31~;idtsOmeX{;rs3A5o0sM) z=fb&~mKk>1GM)Sqw#oMVnh+TWX&>f}N0TSU2f5>7 zcx>d^(;Tq{xGT{xkx0WMT<7VN#Olx(56Y>ruqXkCIMsLd43L>L2F8WZ4)Vq{JffJx zV^PI6jM!d|Ju(G*Qg(v!=u<&)FN_R|h91#J#cId{Td{!3F)AreX{|0Mh&U{x#xp<*RkzoeKWqr76iEKDetD@c;1jo8LB1+R_%$0u*QC8B|XS@-K-yL4HGYluFxvPX8%A zX;0gEOFfkGR+8ESoW8n!rRw#=@SSO*fLJkZ`h)7mEL%4_`bTQ!ii5CDS z4u4V(5|GaR#!P?C-}Jqwr@E*2=D8*~NA>HcdOml$rz3B)%wAYo^ zl4)DsU!U1}bH`j;*1zM9C+q(f6lq8CiCc$n9?o3NRc>3T49@SqZ*R|gYi3hfZ`0h- z1@ErB%ZGB_J9TQ=LIIYBtgm_Qg{*JawDsY7iuI_Y%sCsEIRf>#B7j1i9Mmbu+*(r& zC?)AAC<1A^oVWmWp@jGW5*1Rd47xCWytx!|7}Y{1b-Gv80Fe>Lq9CUeL&P{9mx9EK z)6G!Le?h1_%$@7qh6>{<^R->^JyxdJbvsr(1!H-a7N3O&P(Df>ur7$*D-zL-O9Ql` zQi@w^79vD0UWB?I!k>i1Hbp&fdv829^PF6@E$41oaJSDNo9fAXt8b)cQkmAAcguoz z`@DbZM4qj@ab)I5#+GBb1$OJ)F`3!=HCxh1c;{N0nZA6yiTM14HQ-h)bF{pk$LQEp zp0x&)Wi=F#L3wLm_N#imh__yUj(XqyZUPmCZTq4d;=tuiVRo`kqUdXo_V_)~YJ`f~R%fwCLWEb?><2&bgmlaCd#& zwCFmSb)CG|nsc28UDEB(dwsw)9KK~cRZ(^0+RU|#HRs(ZZ{C~r?p^SP@7n*H>n~j& zKbJdjT0R%f9tg|!aGv$vI52Zy_GFITAUEyFvU_A^&*K!#zz7Lb`7e+uFa1HEdYjT%jTN3{6c~ z7;3^Auo>(~r!O!=j6GF18GL~p2uM+gCu0++Uyg(B2o@?Bk#Ur==)y*3cCNP_jYVWp zX!e8eP@$g0g;Ffty}f696pGZzAlAkbs!0Sf3fpRc!i- zvRLY-nI#Y|d+&Q&@~*19zvfoU&6do8oWFU&|E+oEefzuiJGHr%gYb5@chT3A_4VX@ zC#J0m>zf^to6jxOocpx0c9{iC%Puf&zJ@riK7w+F z*2#;=nd3!rCQe-6C~4w}co2LC#IxJRjmKfNREMv&*Gbh?jP3`0Pu*&j=0qk!rNr98 zE#8BgFz(#b6iudlu@z~54Ss0oiz0o!dJP!k901HV-t=emEgGz+HlSMbFTs8Wn-y#h z-Cd1&-Cr#Nho#Qw{KwS3Q?Y83Dq=v797l$QxNvPud@|KqScQ-Z2^WNyc&;}naE0AXjS z8vg52EU+fTzUrq_?Bg0y;wp#>xKrUZZ$Hp15~4<)>Qw+<9dW|wSaQN}jSO+&K?yh% zupku%ZBEes#TfLguR0U~cJNAqkCj}URC!}y zW*}qFxi>Aix6Sn~y4td?w)qP=*S-bU;k*7t_E?rZmSekQrt51~8U1ACg$7Tn3PQk? zI#x#TYpZZI47#9xS;7rYkQLJTU=U1!ICy^&$@WK{(!u~O;*7-Ca7smU_ZLfJwGR<> zzxNRiy}7_a*$tU1Id-eudML{tl9@y5^zcaOKa^91h|YW?t!~m(BpJ)=L#vqI8amr5 z{jXq+f&_jExRAMETc-_JB7x0iS?(%Lk+*!EYz6xrz0UOY{ps@94BhW*^-i14x+lSB zlqUKvjPNCp({|u^4$uRiFGCJ7yiElILRZSvo!5v&QZd}>;(1I#Tl5{_dmL^+Dpr|IiF(Q)Q@cVDQt8=NP7pfQU7 z7Dglf2nS!p%99R1H;~!1Zbj6vMPjk7I@vxo z76FB6LcK^7e*&=o6aJ)cK>~~Ju9)@Dp19eVb8i4o0_&cRW!ZI8U7y-KdAnzcu}xP^ zCuc8YhUP}@Fn2~icF0e^Bo7YB_Tf)-1qSwn{BV!(Qi|-+4Vxl;Ctgq72gnmoCHU!0@XqRW+Pe$h-MFLLy9eIAMeh~x?$h6g{YCpp2mGlM z{bF=@7~(eY^qvJST@(sr*yKf9yym`YTN#0YsF!SSK`v#%-5gTbq7W2uA67WQBd8L> zQL;xV7Ze~A9D<>vg*Ry=d{l92%^?hh)M(6j=qPwMff%%)l|mIZBJ>5?1R)JZB@Hq- zWIZDC>{T~Mb$nDBLjo&U{hFTfGXVYs8zA$VqCV62|ADI_@2Ua!A7R|UyWzDm@A6Df zW?k#?%~O~0XHLA?nDNN2ZKR+fvpX}IUDqZz1Z7vdQ54Ir+b%b>$*!PL^tDjSTPhvS z-2-8G;W7KLT5h%Mhny=p^8&FArgOu0(f%>%g6W zmAAbIBgP;4;nP;4uT-vrv8NqyUj?;5=S&g2Jxvk#idFE=v>i`G0kTNPGAy@AXWA*2 zwq(;z=*Lyk(x#4%g4NW|&#F1Fyt}9;q%ra4e3Zth5cZ*XR7POjin#HD z*oaCK-+}QdHZ6iqVWi8^v6P+1a60e_rM$>rxIq!_21ig(rP#w97~T-v2ckdK2;QU1 z#GHfsLNOkWsfsE}sY9G%83Ysja;kAnM@5SiNVRLK4Ut`su~|dx>uQElfl{eBc1~fg zhNH>U-V*%^Mye}chl>R=C{4f>C-i8$RL3`J1?-Om=b^D?gw()=KE!Z9Bq!mRinAas zQ*GY}s}}K>+PYe{;NZ1!LcpBv6;r$_&kgC!IJKpu8?+AMiNXr%T4KOXoEB>M5BeX7 z21Rs7BG!qwO+-c>U<+!3Iw>!NE}#h1)O$&(c}z73;dL)@5v!ssq|%B?dJat-L3Dy$ zmtcys;2e?gbP}6FP1L&#CtOA&g@Hp*{Br>K68cj(^VdRm^rR7)|BY zo{uL!zAU%&=DdBfz3+2h%e+75YnwXtsm-V2vCW^FKO#TTCkOg--t)5k{O7j7eA9hf zP^*>$-_CglWc$G9Hg3*;-_{ID6dFmgzxh79WvOyqrf054ZX3*1Mr2pyQ*WUtk*ge& zU1Ll3`b_hjY5w>p_Pr`jm)x!m_jB92%(45njoOstmUB7pv$Fl!&uleIc5bd}?%Eyy z-3y=Cy9t2I?UMHlc4cjqCsy<7ij}=THJHeT+0~CxK$bKbm5o;pUX-5C@w5`!Fl@ zv^unNBk*YPgAY_vKy^%9!lQ)oqx}$d?tqB#WC+4FARt|140r|=%NPzzwf@m$5)YA5 zetdLH63;*$B0n5@SfoVyQ+xghf&T)5?MC(L;14%J9@3^H{uMxTKqpcLGH|9Vs0|#P z=c@G>`*Dh%w&$B#e$o2V*7@h(54{_@8_qR7_3^H3Q`fY0`r3_GXI{P##rp&_kZZB-Z>1Cg0X`DL;It5<~@( zTn`gZ-I?_dT$K;jZ_97oDz_YvHyq42K9%3t zJSSx0Upg#4|FYRu<5@O4s+`MJ)W*R46L*fy2j%q#zTE8hSf_d(Zl%E6_}qOGNhY&x zOWs{~z$JN5W)8~i!93%fKA2^yXNR-QhDByemf7;4dSkwJgUs!fYxd=<4&-YaGr}x( z<#0YU+bq_H;CU=Y2;6SIKHIeC&hc#1k-NRwrl;gh9aB98v`gNW`I`IQoiejiW_OnD zv4!-wQ?A*icV}a;J1cbOp(=Klj#y?lWSNa~&MdQi{&<$zyT}~QGKcfEo8;|B?zyw} z=N_6(&=4YbxH;>6cALy>li6)cj6-%de!{FTRSIAqC_HRLDR`S0)&)jWPVm2nE#OBk z7!84|mSM`%NkhP2DkHYbg}Nat)&=fuM$fHz0dM&i^Z?;{I^J*4gY93?gZ-b?gCmIo ziTrryZB{oZ*POG9ci;BtGguSKi`+r|UGnI;!jrGKZ5Y>U;_&|~-~e;{8?18mV{`Hq z#;WHE;tKrsHL0i0Vf-vGauRPJjG=7}q4DFRaPL+U_O5?F4#z~w3qZyoF5usf~ z_nqQ9ezO0>`D4J<&zY^w|q32l{lc0fZ!WAzlc^!O$P^Z{P(h!HXLN z=P^We!^eI?CN{R9s=659tf3m=cm=6XI8*Vj`~EpLmj$>SKvSObEx% zj-g-;$2!!nW}E~iww7?B@Vuf}NtsxDuP**CoC7A8k@MFO&i}c)dePmSbvNhSTS1Ft zoQq6tmZ{CyZ#TW)`fls{?eDhB2T$J{%(Xr%Gqv}b-U5jCL;pt&A2fWl;e!qGQ|IK~ z{@kwf1m-yd2F%M3n?7p&p!K8n586N8AV1re+ucuK&Kocp_V(sG5-B`fR0u){`$G|- z4oR-t{^5zmy}jAJz4CWnB9-By%HW41i~IYt`}^e=2T5h5sB*i!uZI*KHwrWE`4}lW z3|Cu=Y-5(iXi&CEW||)7(1!Bbwu(a+P6y$~P`mmkvIQ5F&bz^w!SE1fH7x83TL@t; zL|ekAHgq{&UIJ|>g#;Q6%~ih=Xv3)rDzjS$EI%eFEyK%nUWfr!UDOS8#57DrOc&wn z7r1mb_{bU<;j-wGU+=Z>gwm?Qo1TNx8a-clLTO#$P0vAT8JPzk5@-tTP1E&W28?bP z<2s`aT#l?UnliWmw#?ezw!WwBChlzn0>dK^{C*X_62ObCBv_(j@LdsnE=rCia!jd> zBjL&5CWNeJdvZcJGSUW4D>QO7gHN?wQ-1aO3iW1m>%kYoe5$^LMV3@Gl(NU-I#3<) zmO}g`CjS|e2qtI=gXT%9h*gZXX~3g$je7{O9TLUaHvt!kqutk{$s!Z>0@n9pf-a{b z{CM?c_}&cOh&C(?V-aDSG3mpET%i0{$gRFCXGX&i&9VeFDdb%L02#cxte)j_cyE@6 zV9D>k_T44B?>d|JR$v(8!9O{O-_q0vbDnk?!m3&CBD-#ZT}KM{<~;jk`#w_GxWG1& z!hJcG7&8@a{Co!Q2n`OVuFHy_MyKA5krU#t#fs{{GE#>KjpY+cLWd+qS) zmKhLDom{5i@vx4v`15vOzM|@u>!u4(Emk#WtC~sEt=a0Wzjs(HRt&Xs|KnF%pn-&R z{3};Sb(fF9Pb6_+LLp*6f$U_4;On=5l`QEa2L@Qb0J{D~^4VGnM4iZ`xig8lfH6S$ zB_6rs1e*vqR=w|h0J)Txd{>1{@vC+5B49)lR)si<5Dpa=`aH13BM%>qkiObD_%0tC z6i^bXz=NcMR*_+q!e4|jkj?*02NVAea!@*q1x6{FY>C+8z`s+0SE0Z@0rvYPDD)|> zDkT`KMT5AEt?;0eQH!MBUNoyhU*y1GJ%LmhTwv{^1{FcXK($sQ@pAwPRQwV2lW-q^>mWvg+_STi{?uPWK!o&@{D>||2Kj?K%{=) zVT75r8@e4VHaXD-5cwP`&?;iA0Y+aBjg#*;L!uec$e5s`Ow3`j9uoK(ctTQG7rc$&WF)g~?Aa z`57jJZT%JI2&*Hi5;7bSg`=ooLgvO{t{oG!v(*)A!W@b-qBsyQ{3H7j^_%4<#V?=& zxE3Y-2vYczkETDPj{lZw`W@x{4aNS3a{Pw!{f2V>mfG}Ns%F``-_J~Wmcd_dV*kNp zp(~atNLI}7x=daxs9pPhPub{(l`1=3yHaDNgDd_T+Pkuwp|`Dg?DXE18awS!G~WM8CfX=~oQe%TC9a54Zs z8|Lf??ZBK9SnD#2ITvMhuDBt$vbUYyurf%y=*=q|Hq!3pE}CLo)2Rh>{j!O%Z28nv z_hwZl^3KScBXcj$iJ4fgetXW-CgW#p6w~kr{AQ*OouOMkwYz5`vzy--$@IN5@aDiL x_7-TH@x3uRS1H?@{|UXDTOLWMM*J$cuG2yN+F|M3Z~nDsbLTGeuXkbo{{ut>`+NWZ literal 0 HcmV?d00001 diff --git a/scripts/ci/opencode_review_normalize_output.py b/scripts/ci/opencode_review_normalize_output.py index 32145f8f..c3be9821 100755 --- a/scripts/ci/opencode_review_normalize_output.py +++ b/scripts/ci/opencode_review_normalize_output.py @@ -274,5 +274,5 @@ def main(argv: list[str]) -> int: return 4 -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover raise SystemExit(main(sys.argv)) diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index cf4805f0..5f64a850 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +"""Schedule OpenCode reviews and auto-merges for open pull requests.""" from __future__ import annotations import argparse @@ -69,12 +70,15 @@ @dataclass class Decision: + """Holds the scheduling decision for a single pull request.""" + pr: int action: str reason: str def run(args: list[str], *, stdin: str | None = None) -> str: + """Run a subprocess and return its stdout, raising RuntimeError on failure.""" process = subprocess.run(args, input=stdin, capture_output=True, text=True) if process.returncode != 0: raise RuntimeError( @@ -84,6 +88,7 @@ def run(args: list[str], *, stdin: str | None = None) -> str: def split_repo(repo: str) -> tuple[str, str]: + """Split an 'owner/name' repository string into a (owner, name) tuple.""" try: owner, name = repo.split("/", 1) except ValueError as exc: @@ -94,6 +99,7 @@ def split_repo(repo: str) -> tuple[str, str]: def gh_graphql(query: str, **fields: str | int) -> dict[str, Any]: + """Execute a GitHub GraphQL query via the gh CLI and return the parsed JSON response.""" cmd = ["gh", "api", "graphql", "-F", "query=@-"] for key, value in fields.items(): flag = "-F" if isinstance(value, int) else "-f" @@ -102,6 +108,7 @@ def gh_graphql(query: str, **fields: str | int) -> dict[str, Any]: def fetch_open_prs(repo: str, max_prs: int) -> list[dict[str, Any]]: + """Fetch up to max_prs open pull requests from the repository using pagination.""" owner, name = split_repo(repo) prs: list[dict[str, Any]] = [] cursor: str | None = None @@ -126,12 +133,14 @@ def fetch_open_prs(repo: str, max_prs: int) -> list[dict[str, Any]]: def context_nodes(pr: dict[str, Any]) -> list[dict[str, Any]]: + """Return the list of status-check context nodes from a PR's statusCheckRollup.""" rollup = pr.get("statusCheckRollup") or {} contexts = rollup.get("contexts") or {} return contexts.get("nodes") or [] def is_opencode_context(node: dict[str, Any]) -> bool: + """Return True if the status-check node belongs to the OpenCode review workflow.""" if node.get("__typename") == "CheckRun": workflow = ( ((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") @@ -142,6 +151,7 @@ def is_opencode_context(node: dict[str, Any]) -> bool: def opencode_in_progress(pr: dict[str, Any]) -> bool: + """Return True if an OpenCode review is currently queued or running for the PR.""" for node in context_nodes(pr): if not is_opencode_context(node): continue @@ -152,19 +162,23 @@ def opencode_in_progress(pr: dict[str, Any]) -> bool: def unresolved_thread_count(pr: dict[str, Any]) -> int: + """Return the number of non-outdated, unresolved review threads on the PR.""" threads = ((pr.get("reviewThreads") or {}).get("nodes") or []) return sum(1 for thread in threads if not thread.get("isResolved") and not thread.get("isOutdated")) def review_author_login(review: dict[str, Any]) -> str: + """Return the lowercased login of the review author, or empty string if absent.""" return ((review.get("author") or {}).get("login") or "").lower() def is_opencode_review(review: dict[str, Any]) -> bool: + """Return True if the review was submitted by the opencode-agent bot.""" return review_author_login(review) == "opencode-agent" def current_head_review_state(pr: dict[str, Any], state: str) -> bool: + """Return True if the latest opencode-agent review on the current head has the given state.""" head = pr.get("headRefOid") for review in reversed((pr.get("reviews") or {}).get("nodes") or []): if not is_opencode_review(review): @@ -178,14 +192,17 @@ def current_head_review_state(pr: dict[str, Any], state: str) -> bool: def has_current_head_approval(pr: dict[str, Any]) -> bool: + """Return True if the opencode-agent approved the current head commit.""" return current_head_review_state(pr, "APPROVED") def has_current_head_changes_requested(pr: dict[str, Any]) -> bool: + """Return True if the opencode-agent requested changes on the current head commit.""" return current_head_review_state(pr, "CHANGES_REQUESTED") def enable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: + """Enable auto-merge for the PR via gh CLI, unless dry_run is True.""" number = str(pr["number"]) head = pr["headRefOid"] if dry_run: @@ -194,6 +211,7 @@ def enable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: def dispatch_opencode_review(repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool) -> None: + """Trigger the OpenCode review workflow for the PR, unless dry_run is True.""" if dry_run: return run( @@ -230,6 +248,7 @@ def inspect_pr( workflow: str, base_branch: str, ) -> Decision: + """Determine what action to take for a single open pull request.""" number = pr["number"] head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") base_ref = pr.get("baseRefName") @@ -273,6 +292,7 @@ def print_summary( base_branch: str, project_flow: str, ) -> None: + """Print a per-PR decision log and a JSON summary to stdout.""" counts: dict[str, int] = {} for decision in decisions: counts[decision.action] = counts.get(decision.action, 0) + 1 @@ -292,6 +312,7 @@ def print_summary( def self_test() -> None: + """Run built-in assertions to verify core logic is correct.""" sample = { "number": 1, "headRefOid": "abc", @@ -340,6 +361,7 @@ def self_test() -> None: def parse_args(argv: list[str]) -> argparse.Namespace: + """Parse command-line arguments and return the resulting Namespace.""" parser = argparse.ArgumentParser() parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", "")) parser.add_argument("--base-branch", default=os.environ.get("DEFAULT_BRANCH", "")) @@ -354,6 +376,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace: def main(argv: list[str]) -> int: + """Entry point: parse args, fetch PRs, inspect each, print summary.""" args = parse_args(argv) if args.self_test: self_test() @@ -386,7 +409,7 @@ def main(argv: list[str]) -> int: return 0 -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover try: raise SystemExit(main(sys.argv[1:])) except RuntimeError as exc: diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c98a005978d25ad3d413ba052df0dee47e2c0afd GIT binary patch literal 148 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 W-agaf$7y?`JN&`&pgYW4_5TCHm$+g8 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_pr_review_merge_scheduler.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_pr_review_merge_scheduler.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1eac6bb4bca6029e842187abaed384d499703ec9 GIT binary patch literal 86969 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�gW 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 J-o)Rl{~sLr=l=iz literal 0 HcmV?d00001 diff --git a/tests/test_opencode_review_normalize_output.py b/tests/test_opencode_review_normalize_output.py new file mode 100644 index 00000000..d2242eb5 --- /dev/null +++ b/tests/test_opencode_review_normalize_output.py @@ -0,0 +1,458 @@ +"""Tests for scripts/ci/opencode_review_normalize_output.py.""" +from __future__ import annotations + +import json +import sys +from io import StringIO +from pathlib import Path + +import pytest + +from opencode_review_normalize_output import ( + admits_missing_structural_review, + check_structural_approval, + iter_json_objects, + main, + mentions_changed_file_evidence, + valid_control, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_VALID_KWARGS = { + "expected_head_sha": "sha1", + "expected_run_id": "run1", + "expected_run_attempt": "1", +} + + +def _valid_approve_value(**overrides: object) -> dict: + """Return a minimal valid APPROVE control block.""" + base: dict = { + "head_sha": "sha1", + "run_id": "run1", + "run_attempt": "1", + "result": "APPROVE", + "reason": "Reviewed scripts/ci/pr_review_merge_scheduler.py thoroughly.", + "summary": "All changes in scripts/ci/pr_review_merge_scheduler.py look correct.", + "findings": [], + } + base.update(overrides) + return base + + +def _valid_request_changes_value(**overrides: object) -> dict: + """Return a minimal valid REQUEST_CHANGES control block with one finding.""" + finding: dict = { + "path": "scripts/ci/pr_review_merge_scheduler.py", + "line": 10, + "severity": "HIGH", + "title": "Bug in function", + "problem": "The function does X incorrectly.", + "root_cause": "Missing validation.", + "fix_direction": "Add validation.", + "regression_test_direction": "Add a test for the edge case.", + "suggested_diff": "- old\n+ new", + } + base: dict = { + "head_sha": "sha1", + "run_id": "run1", + "run_attempt": "1", + "result": "REQUEST_CHANGES", + "reason": "Found issues in scripts/ci/pr_review_merge_scheduler.py.", + "summary": "See findings.", + "findings": [finding], + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# admits_missing_structural_review() +# --------------------------------------------------------------------------- + + +def test_admits_missing_phrase(): + """Test admits_missing_structural_review() matches a known structural-failure phrase.""" + assert admits_missing_structural_review( + "structural exploration was not possible", "" + ) + + +def test_admits_missing_pattern(): + """Test admits_missing_structural_review() matches a structural-failure regex pattern.""" + assert admits_missing_structural_review( + "Could not inspect the changed files", "" + ) + + +def test_admits_missing_no_match(): + """Test admits_missing_structural_review() returns False for clean text.""" + assert not admits_missing_structural_review( + "Reviewed all changes in scripts/ci/foo.py and they look good.", "" + ) + + +def test_admits_missing_phrase_in_summary(): + """Test admits_missing_structural_review() also checks the summary argument.""" + assert admits_missing_structural_review("", "no changed files") + + +# --------------------------------------------------------------------------- +# mentions_changed_file_evidence() +# --------------------------------------------------------------------------- + + +def test_mentions_file_path(): + """Test mentions_changed_file_evidence() detects a directory/file path.""" + assert mentions_changed_file_evidence("Changed scripts/ci/main.py", "") + + +def test_mentions_extension_file(): + """Test mentions_changed_file_evidence() detects a bare filename with extension.""" + assert mentions_changed_file_evidence("", "Updated requirements.txt") + + +def test_mentions_no_file(): + """Test mentions_changed_file_evidence() returns False when no file path is present.""" + assert not mentions_changed_file_evidence("looks good overall", "no files here") + + +# --------------------------------------------------------------------------- +# check_structural_approval() +# --------------------------------------------------------------------------- + + +def test_check_structural_approval_file_not_found(tmp_path: Path) -> None: + """Test check_structural_approval() returns 65 when the file does not exist.""" + result = check_structural_approval(tmp_path / "nonexistent.json") + assert result == 65 + + +def test_check_structural_approval_invalid_json(tmp_path: Path) -> None: + """Test check_structural_approval() returns 65 on a JSON parse error.""" + bad = tmp_path / "bad.json" + bad.write_text("not json{{{") + result = check_structural_approval(bad) + assert result == 65 + + +def test_check_structural_approval_not_dict(tmp_path: Path) -> None: + """Test check_structural_approval() returns 4 when the JSON value is not a dict.""" + f = tmp_path / "control.json" + f.write_text('"just a string"') + assert check_structural_approval(f) == 4 + + +def test_check_structural_approval_approve_admits_missing(tmp_path: Path) -> None: + """Test check_structural_approval() returns 4 when APPROVE admits missing structure.""" + f = tmp_path / "control.json" + f.write_text( + json.dumps( + { + "result": "APPROVE", + "reason": "structural exploration was not possible", + "summary": "ok", + } + ) + ) + assert check_structural_approval(f) == 4 + + +def test_check_structural_approval_approve_no_file_evidence(tmp_path: Path) -> None: + """Test check_structural_approval() returns 4 when APPROVE lacks file evidence.""" + f = tmp_path / "control.json" + f.write_text(json.dumps({"result": "APPROVE", "reason": "looks good", "summary": "ok"})) + assert check_structural_approval(f) == 4 + + +def test_check_structural_approval_approve_valid(tmp_path: Path) -> None: + """Test check_structural_approval() returns 0 for a properly evidenced APPROVE.""" + f = tmp_path / "control.json" + f.write_text( + json.dumps( + { + "result": "APPROVE", + "reason": "Reviewed scripts/ci/pr_review_merge_scheduler.py.", + "summary": "All changes look correct.", + } + ) + ) + assert check_structural_approval(f) == 0 + + +def test_check_structural_approval_request_changes(tmp_path: Path) -> None: + """Test check_structural_approval() returns 0 for a REQUEST_CHANGES block.""" + f = tmp_path / "control.json" + f.write_text( + json.dumps({"result": "REQUEST_CHANGES", "reason": "issues found", "summary": "fix it"}) + ) + assert check_structural_approval(f) == 0 + + +# --------------------------------------------------------------------------- +# valid_control() +# --------------------------------------------------------------------------- + + +def test_valid_control_not_dict() -> None: + """Test valid_control() returns None for non-dict input.""" + assert valid_control("string", **_VALID_KWARGS) is None + + +def test_valid_control_wrong_head_sha() -> None: + """Test valid_control() returns None when head_sha does not match.""" + v = _valid_approve_value(head_sha="wrong") + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_wrong_run_id() -> None: + """Test valid_control() returns None when run_id does not match.""" + v = _valid_approve_value(run_id="wrong") + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_wrong_run_attempt() -> None: + """Test valid_control() returns None when run_attempt does not match.""" + v = _valid_approve_value(run_attempt="99") + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_invalid_result() -> None: + """Test valid_control() returns None for an unrecognized result value.""" + v = _valid_approve_value(result="MAYBE") + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_reason_not_str() -> None: + """Test valid_control() returns None when reason is not a string.""" + v = _valid_approve_value(reason=123) + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_reason_empty() -> None: + """Test valid_control() returns None when reason is an empty/whitespace string.""" + v = _valid_approve_value(reason=" ") + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_summary_not_str() -> None: + """Test valid_control() returns None when summary is not a string.""" + v = _valid_approve_value(summary=None) + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_summary_empty() -> None: + """Test valid_control() returns None when summary is an empty string.""" + v = _valid_approve_value(summary="") + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_findings_none_approve() -> None: + """Test valid_control() sets findings to [] for APPROVE when findings is None.""" + v = _valid_approve_value() + del v["findings"] + result = valid_control(v, **_VALID_KWARGS) + assert result is not None + assert result["findings"] == [] + + +def test_valid_control_findings_not_list_request_changes() -> None: + """Test valid_control() returns None when findings is not a list for REQUEST_CHANGES.""" + v = _valid_request_changes_value(findings="not a list") + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_approve_with_non_empty_findings() -> None: + """Test valid_control() returns None when APPROVE has non-empty findings.""" + finding = _valid_request_changes_value()["findings"][0] + v = _valid_approve_value(findings=[finding]) + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_request_changes_empty_findings() -> None: + """Test valid_control() returns None when REQUEST_CHANGES has no findings.""" + v = _valid_request_changes_value(findings=[]) + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_approve_admits_missing() -> None: + """Test valid_control() returns None for APPROVE that admits missing structure.""" + v = _valid_approve_value( + reason="structural exploration was not possible", + summary="ok", + ) + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_approve_no_file_evidence() -> None: + """Test valid_control() returns None for APPROVE with no changed file evidence.""" + v = _valid_approve_value(reason="everything looks fine", summary="all good") + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_approve_valid() -> None: + """Test valid_control() returns a normalized dict for a valid APPROVE.""" + v = _valid_approve_value() + result = valid_control(v, **_VALID_KWARGS) + assert result is not None + assert result["result"] == "APPROVE" + assert result["findings"] == [] + + +def test_valid_control_request_changes_valid() -> None: + """Test valid_control() returns a normalized dict for a valid REQUEST_CHANGES.""" + v = _valid_request_changes_value() + result = valid_control(v, **_VALID_KWARGS) + assert result is not None + assert result["result"] == "REQUEST_CHANGES" + assert len(result["findings"]) == 1 + + +def test_valid_control_finding_not_dict() -> None: + """Test valid_control() returns None when a finding entry is not a dict.""" + v = _valid_request_changes_value(findings=["not-a-dict"]) + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_finding_line_is_bool() -> None: + """Test valid_control() returns None when finding line is a boolean (not an int).""" + finding = dict(_valid_request_changes_value()["findings"][0]) + finding["line"] = True + v = _valid_request_changes_value(findings=[finding]) + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_finding_line_not_int() -> None: + """Test valid_control() returns None when finding line is not an integer.""" + finding = dict(_valid_request_changes_value()["findings"][0]) + finding["line"] = "ten" + v = _valid_request_changes_value(findings=[finding]) + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_finding_line_zero() -> None: + """Test valid_control() returns None when finding line is <= 0.""" + finding = dict(_valid_request_changes_value()["findings"][0]) + finding["line"] = 0 + v = _valid_request_changes_value(findings=[finding]) + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_finding_missing_required_field() -> None: + """Test valid_control() returns None when a required finding field is absent.""" + finding = dict(_valid_request_changes_value()["findings"][0]) + del finding["title"] + v = _valid_request_changes_value(findings=[finding]) + assert valid_control(v, **_VALID_KWARGS) is None + + +def test_valid_control_finding_empty_required_field() -> None: + """Test valid_control() returns None when a required finding field is empty.""" + finding = dict(_valid_request_changes_value()["findings"][0]) + finding["title"] = " " + v = _valid_request_changes_value(findings=[finding]) + assert valid_control(v, **_VALID_KWARGS) is None + + +# --------------------------------------------------------------------------- +# iter_json_objects() +# --------------------------------------------------------------------------- + + +def test_iter_json_objects_plain_json() -> None: + """Test iter_json_objects() parses a plain JSON string.""" + result = iter_json_objects('{"key": "value"}') + # Should contain at least the parsed object + assert {"key": "value"} in result + + +def test_iter_json_objects_prose_with_json() -> None: + """Test iter_json_objects() finds embedded JSON in surrounding prose.""" + text = 'Some text here {"result": "APPROVE"} more text' + result = iter_json_objects(text) + assert any(isinstance(v, dict) and v.get("result") == "APPROVE" for v in result) + + +def test_iter_json_objects_invalid_json() -> None: + """Test iter_json_objects() handles text that is not valid JSON gracefully.""" + result = iter_json_objects("not json at all") + # No valid JSON objects found + assert result == [] + + +def test_iter_json_objects_invalid_brace() -> None: + """Test iter_json_objects() skips braces that start invalid JSON.""" + result = iter_json_objects("{invalid}") + assert result == [] + + +# --------------------------------------------------------------------------- +# main() +# --------------------------------------------------------------------------- + + +def test_main_wrong_arg_count(capsys: pytest.CaptureFixture) -> None: + """Test main() returns 64 and prints usage when called with wrong number of args.""" + result = main(["prog"]) + assert result == 64 + assert "usage" in capsys.readouterr().err.lower() + + +def test_main_check_structural_approval_delegates(tmp_path: Path) -> None: + """Test main() delegates to check_structural_approval for --check-structural-approval.""" + f = tmp_path / "ctrl.json" + f.write_text( + json.dumps( + { + "result": "APPROVE", + "reason": "Reviewed scripts/ci/foo.py.", + "summary": "ok", + } + ) + ) + result = main(["prog", "--check-structural-approval", str(f)]) + assert result == 0 + + +def test_main_file_not_found(tmp_path: Path, capsys: pytest.CaptureFixture) -> None: + """Test main() returns 65 when the output file cannot be read.""" + result = main(["prog", "sha1", "run1", "1", str(tmp_path / "missing.txt")]) + assert result == 65 + assert "cannot read" in capsys.readouterr().err + + +def test_main_no_valid_control(tmp_path: Path, capsys: pytest.CaptureFixture) -> None: + """Test main() returns 4 when no valid control block is found in the file.""" + f = tmp_path / "output.txt" + f.write_text("just some prose, no valid control block here") + result = main(["prog", "sha1", "run1", "1", str(f)]) + assert result == 4 + assert "NO_CONCLUSION" in capsys.readouterr().err + + +def test_main_valid_control_written(tmp_path: Path) -> None: + """Test main() writes the normalized control block and returns 0.""" + control = _valid_approve_value() + f = tmp_path / "output.txt" + f.write_text(json.dumps(control)) + result = main(["prog", "sha1", "run1", "1", str(f)]) + assert result == 0 + written = f.read_text(encoding="utf-8") + assert "opencode-review-control-v1" in written + assert "APPROVE" in written + + +def test_main_first_invalid_second_valid(tmp_path: Path) -> None: + """Test main() skips invalid control blocks and uses the first valid one.""" + invalid = {"head_sha": "sha1", "run_id": "run1", "run_attempt": "1", "result": "MAYBE"} + valid = _valid_approve_value() + f = tmp_path / "output.txt" + # Put invalid first so the `continue` branch is exercised, then valid second + f.write_text(json.dumps(invalid) + "\n" + json.dumps(valid)) + result = main(["prog", "sha1", "run1", "1", str(f)]) + assert result == 0 + written = f.read_text(encoding="utf-8") + assert "APPROVE" in written diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py new file mode 100644 index 00000000..637b9281 --- /dev/null +++ b/tests/test_pr_review_merge_scheduler.py @@ -0,0 +1,868 @@ +"""Tests for scripts/ci/pr_review_merge_scheduler.py.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, call, patch + +import pytest + +import pr_review_merge_scheduler as sched +from pr_review_merge_scheduler import ( + Decision, + context_nodes, + current_head_review_state, + dispatch_opencode_review, + enable_auto_merge, + fetch_open_prs, + gh_graphql, + has_current_head_approval, + has_current_head_changes_requested, + inspect_pr, + is_opencode_context, + is_opencode_review, + main, + opencode_in_progress, + parse_args, + print_summary, + review_author_login, + run, + self_test, + split_repo, + unresolved_thread_count, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_BASE_PR: dict = { + "number": 42, + "headRefOid": "abc123", + "isDraft": False, + "baseRefName": "main", + "headRefName": "feature", + "baseRefOid": "base123", + "headRepository": {"nameWithOwner": "owner/repo"}, + "autoMergeRequest": None, + "reviewThreads": {"nodes": []}, + "reviews": {"nodes": []}, + "statusCheckRollup": {"contexts": {"nodes": []}}, +} + + +def _make_pr(**overrides: object) -> dict: + """Return a copy of the base PR dict with the given overrides applied.""" + pr = dict(_BASE_PR) + pr.update(overrides) + return pr + + +def _approved_pr(head: str = "abc123") -> dict: + """Return a PR dict that has a current-head approval from opencode-agent.""" + return _make_pr( + headRefOid=head, + reviews={ + "nodes": [ + { + "state": "APPROVED", + "author": {"login": "opencode-agent"}, + "body": "", + "commit": {"oid": head}, + } + ] + }, + ) + + +# --------------------------------------------------------------------------- +# run() +# --------------------------------------------------------------------------- + + +def test_run_success(): + """Test that run() returns stdout on a successful command.""" + with patch("subprocess.run") as mock_sub: + mock_sub.return_value = MagicMock(returncode=0, stdout="hello\n", stderr="") + assert run(["echo", "hello"]) == "hello\n" + + +def test_run_failure(): + """Test that run() raises RuntimeError when the subprocess exits non-zero.""" + with patch("subprocess.run") as mock_sub: + mock_sub.return_value = MagicMock(returncode=1, stdout="", stderr="oops") + with pytest.raises(RuntimeError, match="Command failed"): + run(["false"]) + + +def test_run_passes_stdin(): + """Test that run() forwards the stdin argument to subprocess.""" + with patch("subprocess.run") as mock_sub: + mock_sub.return_value = MagicMock(returncode=0, stdout="", stderr="") + run(["cat"], stdin="data") + _, kwargs = mock_sub.call_args + assert kwargs.get("input") == "data" or mock_sub.call_args[0][1] == "data" or True + # The important check is that subprocess.run was called + mock_sub.assert_called_once() + + +# --------------------------------------------------------------------------- +# split_repo() +# --------------------------------------------------------------------------- + + +def test_split_repo_valid(): + """Test that split_repo() splits 'owner/name' into a tuple.""" + assert split_repo("owner/repo") == ("owner", "repo") + + +def test_split_repo_with_nested_slash(): + """Test that split_repo() only splits on the first slash.""" + assert split_repo("owner/some/deep") == ("owner", "some/deep") + + +def test_split_repo_no_slash(): + """Test that split_repo() raises ValueError when there is no slash.""" + with pytest.raises(ValueError, match="owner/name"): + split_repo("noslash") + + +def test_split_repo_empty_owner(): + """Test that split_repo() raises ValueError when owner is empty.""" + with pytest.raises(ValueError, match="owner/name"): + split_repo("/repo") + + +def test_split_repo_empty_name(): + """Test that split_repo() raises ValueError when name is empty.""" + with pytest.raises(ValueError, match="owner/name"): + split_repo("owner/") + + +# --------------------------------------------------------------------------- +# gh_graphql() +# --------------------------------------------------------------------------- + + +def test_gh_graphql_string_field(): + """Test that gh_graphql() uses -f for string-valued fields.""" + with patch("pr_review_merge_scheduler.run") as mock_run: + mock_run.return_value = '{"data": {}}' + result = gh_graphql("query {}", owner="myowner") + args_passed = mock_run.call_args[0][0] + assert "-f" in args_passed + assert any("owner=myowner" in a for a in args_passed) + assert result == {"data": {}} + + +def test_gh_graphql_int_field(): + """Test that gh_graphql() uses -F for integer-valued fields.""" + with patch("pr_review_merge_scheduler.run") as mock_run: + mock_run.return_value = '{"data": {}}' + gh_graphql("query {}", pageSize=50) + args_passed = mock_run.call_args[0][0] + assert "-F" in args_passed + assert any("pageSize=50" in a for a in args_passed) + + +def test_gh_graphql_passes_query_as_stdin(): + """Test that gh_graphql() passes the query string as stdin.""" + with patch("pr_review_merge_scheduler.run") as mock_run: + mock_run.return_value = '{"data": {}}' + gh_graphql("my query") + _, kwargs = mock_run.call_args + assert kwargs.get("stdin") == "my query" + + +# --------------------------------------------------------------------------- +# fetch_open_prs() +# --------------------------------------------------------------------------- + + +def _gql_page(nodes: list, *, has_next: bool, cursor: str | None = None) -> dict: + """Build a mock gh_graphql response for a page of pull requests.""" + return { + "data": { + "repository": { + "pullRequests": { + "pageInfo": {"hasNextPage": has_next, "endCursor": cursor}, + "nodes": nodes, + } + } + } + } + + +def test_fetch_open_prs_single_page(): + """Test fetch_open_prs() with a single page of results.""" + nodes = [{"number": 1}] + with patch("pr_review_merge_scheduler.gh_graphql") as mock_gql: + mock_gql.return_value = _gql_page(nodes, has_next=False) + prs = fetch_open_prs("owner/repo", max_prs=100) + assert prs == nodes + mock_gql.assert_called_once() + # No cursor in first call + first_kwargs = mock_gql.call_args[1] + assert "cursor" not in first_kwargs + + +def test_fetch_open_prs_pagination(): + """Test fetch_open_prs() follows pagination to collect all PRs.""" + page1 = _gql_page([{"number": 1}], has_next=True, cursor="cur1") + page2 = _gql_page([{"number": 2}], has_next=False) + with patch("pr_review_merge_scheduler.gh_graphql") as mock_gql: + mock_gql.side_effect = [page1, page2] + prs = fetch_open_prs("owner/repo", max_prs=100) + assert len(prs) == 2 + # Second call must include cursor + second_kwargs = mock_gql.call_args_list[1][1] + assert second_kwargs.get("cursor") == "cur1" + + +def test_fetch_open_prs_max_prs_stops_loop(): + """Test fetch_open_prs() stops collecting when max_prs is reached.""" + with patch("pr_review_merge_scheduler.gh_graphql") as mock_gql: + mock_gql.return_value = _gql_page([{"number": 1}], has_next=True, cursor="c") + prs = fetch_open_prs("owner/repo", max_prs=1) + assert len(prs) == 1 + mock_gql.assert_called_once() + + +# --------------------------------------------------------------------------- +# context_nodes() +# --------------------------------------------------------------------------- + + +def test_context_nodes_normal(): + """Test context_nodes() extracts nodes from a normal PR.""" + pr = _make_pr(statusCheckRollup={"contexts": {"nodes": [{"a": 1}]}}) + assert context_nodes(pr) == [{"a": 1}] + + +def test_context_nodes_missing(): + """Test context_nodes() returns an empty list when statusCheckRollup is absent.""" + assert context_nodes({}) == [] + + +def test_context_nodes_none_values(): + """Test context_nodes() handles None at every level gracefully.""" + assert context_nodes({"statusCheckRollup": None}) == [] + + +# --------------------------------------------------------------------------- +# is_opencode_context() +# --------------------------------------------------------------------------- + + +def test_is_opencode_context_checkrun_by_name(): + """Test is_opencode_context() matches a CheckRun named opencode-review.""" + node = { + "__typename": "CheckRun", + "name": "opencode-review", + "status": "COMPLETED", + "checkSuite": None, + } + assert is_opencode_context(node) + + +def test_is_opencode_context_checkrun_by_workflow_name(): + """Test is_opencode_context() matches a CheckRun with the OpenCode Review workflow name.""" + node = { + "__typename": "CheckRun", + "name": "some-job", + "checkSuite": { + "workflowRun": {"workflow": {"name": "OpenCode Review"}} + }, + } + assert is_opencode_context(node) + + +def test_is_opencode_context_checkrun_no_match(): + """Test is_opencode_context() returns False for an unrelated CheckRun.""" + node = { + "__typename": "CheckRun", + "name": "ci-tests", + "checkSuite": {"workflowRun": {"workflow": {"name": "CI"}}}, + } + assert not is_opencode_context(node) + + +def test_is_opencode_context_status_context_match(): + """Test is_opencode_context() matches a StatusContext with opencode-review context.""" + node = {"__typename": "StatusContext", "context": "opencode-review", "state": "success"} + assert is_opencode_context(node) + + +def test_is_opencode_context_status_context_no_match(): + """Test is_opencode_context() returns False for an unrelated StatusContext.""" + node = {"__typename": "StatusContext", "context": "other-check", "state": "success"} + assert not is_opencode_context(node) + + +# --------------------------------------------------------------------------- +# opencode_in_progress() +# --------------------------------------------------------------------------- + + +def _checkrun_node(name: str, status: str) -> dict: + """Build a minimal CheckRun node dict for testing.""" + return {"__typename": "CheckRun", "name": name, "status": status, "checkSuite": None} + + +def test_opencode_in_progress_queued(): + """Test opencode_in_progress() returns True when status is QUEUED.""" + pr = _make_pr( + statusCheckRollup={"contexts": {"nodes": [_checkrun_node("opencode-review", "QUEUED")]}} + ) + assert opencode_in_progress(pr) + + +def test_opencode_in_progress_in_progress(): + """Test opencode_in_progress() returns True when status is IN_PROGRESS.""" + pr = _make_pr( + statusCheckRollup={ + "contexts": {"nodes": [_checkrun_node("opencode-review", "IN_PROGRESS")]} + } + ) + assert opencode_in_progress(pr) + + +def test_opencode_in_progress_completed(): + """Test opencode_in_progress() returns False when status is COMPLETED.""" + pr = _make_pr( + statusCheckRollup={ + "contexts": {"nodes": [_checkrun_node("opencode-review", "COMPLETED")]} + } + ) + assert not opencode_in_progress(pr) + + +def test_opencode_in_progress_non_opencode_node(): + """Test opencode_in_progress() ignores non-opencode nodes.""" + pr = _make_pr( + statusCheckRollup={ + "contexts": {"nodes": [_checkrun_node("ci-tests", "IN_PROGRESS")]} + } + ) + assert not opencode_in_progress(pr) + + +def test_opencode_in_progress_empty_status(): + """Test opencode_in_progress() treats an empty status as not in progress.""" + pr = _make_pr( + statusCheckRollup={"contexts": {"nodes": [_checkrun_node("opencode-review", "")]}} + ) + assert not opencode_in_progress(pr) + + +def test_opencode_in_progress_no_nodes(): + """Test opencode_in_progress() returns False when there are no nodes.""" + assert not opencode_in_progress(_BASE_PR) + + +# --------------------------------------------------------------------------- +# unresolved_thread_count() +# --------------------------------------------------------------------------- + + +def test_unresolved_thread_count_none(): + """Test unresolved_thread_count() returns 0 when there are no threads.""" + assert unresolved_thread_count(_make_pr()) == 0 + + +def test_unresolved_thread_count_mixed(): + """Test unresolved_thread_count() counts only non-outdated unresolved threads.""" + pr = _make_pr( + reviewThreads={ + "nodes": [ + {"isResolved": False, "isOutdated": False}, # counts + {"isResolved": True, "isOutdated": False}, # resolved + {"isResolved": False, "isOutdated": True}, # outdated + ] + } + ) + assert unresolved_thread_count(pr) == 1 + + +# --------------------------------------------------------------------------- +# review_author_login() +# --------------------------------------------------------------------------- + + +def test_review_author_login_present(): + """Test review_author_login() returns the lowercased login.""" + assert review_author_login({"author": {"login": "OpenCode-Agent"}}) == "opencode-agent" + + +def test_review_author_login_missing_author(): + """Test review_author_login() returns an empty string when author is absent.""" + assert review_author_login({}) == "" + + +def test_review_author_login_null_login(): + """Test review_author_login() handles a None login value.""" + assert review_author_login({"author": {"login": None}}) == "" + + +# --------------------------------------------------------------------------- +# is_opencode_review() +# --------------------------------------------------------------------------- + + +def test_is_opencode_review_true(): + """Test is_opencode_review() returns True for opencode-agent.""" + assert is_opencode_review({"author": {"login": "opencode-agent"}}) + + +def test_is_opencode_review_false(): + """Test is_opencode_review() returns False for other authors.""" + assert not is_opencode_review({"author": {"login": "human-reviewer"}}) + + +# --------------------------------------------------------------------------- +# current_head_review_state() +# --------------------------------------------------------------------------- + + +def test_current_head_review_state_no_reviews(): + """Test current_head_review_state() returns False when there are no reviews.""" + assert not current_head_review_state(_make_pr(), "APPROVED") + + +def test_current_head_review_state_match(): + """Test current_head_review_state() finds an opencode-agent approval on the head.""" + pr = _approved_pr("sha1") + assert current_head_review_state(pr, "APPROVED") + + +def test_current_head_review_state_non_opencode(): + """Test current_head_review_state() ignores reviews from non-opencode authors.""" + pr = _make_pr( + headRefOid="sha1", + reviews={ + "nodes": [ + { + "state": "APPROVED", + "author": {"login": "human"}, + "commit": {"oid": "sha1"}, + } + ] + }, + ) + assert not current_head_review_state(pr, "APPROVED") + + +def test_current_head_review_state_wrong_state(): + """Test current_head_review_state() returns False when state does not match.""" + pr = _make_pr( + headRefOid="sha1", + reviews={ + "nodes": [ + { + "state": "CHANGES_REQUESTED", + "author": {"login": "opencode-agent"}, + "commit": {"oid": "sha1"}, + } + ] + }, + ) + assert not current_head_review_state(pr, "APPROVED") + + +def test_current_head_review_state_old_commit(): + """Test current_head_review_state() returns False when review is on an old commit.""" + pr = _make_pr( + headRefOid="new-sha", + reviews={ + "nodes": [ + { + "state": "APPROVED", + "author": {"login": "opencode-agent"}, + "commit": {"oid": "old-sha"}, + } + ] + }, + ) + assert not current_head_review_state(pr, "APPROVED") + + +# --------------------------------------------------------------------------- +# has_current_head_approval / has_current_head_changes_requested +# --------------------------------------------------------------------------- + + +def test_has_current_head_approval_true(): + """Test has_current_head_approval() returns True for a current-head approval.""" + assert has_current_head_approval(_approved_pr()) + + +def test_has_current_head_approval_false(): + """Test has_current_head_approval() returns False when no approval exists.""" + assert not has_current_head_approval(_make_pr()) + + +def test_has_current_head_changes_requested_true(): + """Test has_current_head_changes_requested() detects a current-head changes-requested review.""" + pr = _make_pr( + headRefOid="sha1", + reviews={ + "nodes": [ + { + "state": "CHANGES_REQUESTED", + "author": {"login": "opencode-agent"}, + "commit": {"oid": "sha1"}, + } + ] + }, + ) + assert has_current_head_changes_requested(pr) + + +def test_has_current_head_changes_requested_false(): + """Test has_current_head_changes_requested() returns False when no such review exists.""" + assert not has_current_head_changes_requested(_make_pr()) + + +# --------------------------------------------------------------------------- +# enable_auto_merge() +# --------------------------------------------------------------------------- + + +def test_enable_auto_merge_dry_run(): + """Test enable_auto_merge() does nothing when dry_run is True.""" + with patch("pr_review_merge_scheduler.run") as mock_run: + enable_auto_merge("owner/repo", {"number": 1, "headRefOid": "sha"}, dry_run=True) + mock_run.assert_not_called() + + +def test_enable_auto_merge_not_dry_run(): + """Test enable_auto_merge() calls gh pr merge when not in dry_run mode.""" + with patch("pr_review_merge_scheduler.run") as mock_run: + enable_auto_merge("owner/repo", {"number": 42, "headRefOid": "sha"}, dry_run=False) + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert "gh" in cmd + assert "--auto" in cmd + assert "42" in cmd + + +# --------------------------------------------------------------------------- +# dispatch_opencode_review() +# --------------------------------------------------------------------------- + + +def test_dispatch_opencode_review_dry_run(): + """Test dispatch_opencode_review() does nothing when dry_run is True.""" + pr = { + "number": 1, + "baseRefName": "main", + "baseRefOid": "base", + "headRefName": "feat", + "headRefOid": "head", + } + with patch("pr_review_merge_scheduler.run") as mock_run: + dispatch_opencode_review("owner/repo", "wf", pr, dry_run=True) + mock_run.assert_not_called() + + +def test_dispatch_opencode_review_not_dry_run(): + """Test dispatch_opencode_review() calls gh workflow run when not in dry_run mode.""" + pr = { + "number": 7, + "baseRefName": "main", + "baseRefOid": "base", + "headRefName": "feat", + "headRefOid": "head", + } + with patch("pr_review_merge_scheduler.run") as mock_run: + dispatch_opencode_review("owner/repo", "OpenCode Review", pr, dry_run=False) + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert "workflow" in cmd + assert "run" in cmd + + +# --------------------------------------------------------------------------- +# inspect_pr() +# --------------------------------------------------------------------------- + +_INSPECT_DEFAULTS: dict = { + "repo": "owner/repo", + "dry_run": True, + "trigger_reviews": True, + "enable_auto_merge_flag": True, + "workflow": "OpenCode Review", + "base_branch": "main", +} + + +def _inspect(pr: dict, **kwargs: object) -> Decision: + """Call inspect_pr() with default kwargs merged with caller overrides.""" + opts = {**_INSPECT_DEFAULTS, **kwargs} + return inspect_pr(opts.pop("repo"), pr, **opts) # type: ignore[arg-type] + + +def test_inspect_pr_draft(): + """Test inspect_pr() returns 'skip' for a draft PR.""" + d = _inspect(_make_pr(isDraft=True)) + assert d.action == "skip" + assert "draft" in d.reason + + +def test_inspect_pr_wrong_base_branch(): + """Test inspect_pr() returns 'skip' when the base branch is unexpected.""" + d = _inspect(_make_pr(baseRefName="develop")) + assert d.action == "skip" + assert "base branch" in d.reason + + +def test_inspect_pr_fork(): + """Test inspect_pr() returns 'skip' for fork or external head repo.""" + d = _inspect(_make_pr(**{"headRepository": {"nameWithOwner": "other/repo"}})) + assert d.action == "skip" + assert "fork" in d.reason + + +def test_inspect_pr_unresolved_thread(): + """Test inspect_pr() returns 'block' when there are unresolved review threads.""" + pr = _make_pr( + reviewThreads={"nodes": [{"isResolved": False, "isOutdated": False}]} + ) + d = _inspect(pr) + assert d.action == "block" + assert "unresolved" in d.reason + + +def test_inspect_pr_changes_requested(): + """Test inspect_pr() returns 'block' when current head has changes requested.""" + pr = _make_pr( + headRefOid="sha1", + reviews={ + "nodes": [ + { + "state": "CHANGES_REQUESTED", + "author": {"login": "opencode-agent"}, + "commit": {"oid": "sha1"}, + } + ] + }, + ) + d = _inspect(pr) + assert d.action == "block" + + +def test_inspect_pr_approved_auto_merge_already_enabled(): + """Test inspect_pr() returns 'wait' when current head is approved and auto-merge is already set.""" + pr = dict(_approved_pr()) + pr["autoMergeRequest"] = {"enabledAt": "2026-01-01"} + d = _inspect(pr) + assert d.action == "wait" + assert "already" in d.reason + + +def test_inspect_pr_approved_auto_merge_flag_disabled(): + """Test inspect_pr() returns 'wait' when approved but auto-merge flag is False.""" + d = _inspect(_approved_pr(), enable_auto_merge_flag=False) + assert d.action == "wait" + assert "disabled" in d.reason + + +def test_inspect_pr_approved_enables_auto_merge(): + """Test inspect_pr() enables auto-merge and returns 'auto_merge' when conditions are met.""" + with patch("pr_review_merge_scheduler.run"): + d = _inspect(_approved_pr(), dry_run=False) + assert d.action == "auto_merge" + + +def test_inspect_pr_opencode_in_progress(): + """Test inspect_pr() returns 'wait' when an OpenCode review is already running.""" + pr = _make_pr( + statusCheckRollup={ + "contexts": {"nodes": [_checkrun_node("opencode-review", "IN_PROGRESS")]} + } + ) + d = _inspect(pr) + assert d.action == "wait" + assert "in progress" in d.reason + + +def test_inspect_pr_triggers_review(): + """Test inspect_pr() dispatches a review and returns 'review_dispatch' when trigger is True.""" + with patch("pr_review_merge_scheduler.run"): + d = _inspect(_make_pr(), dry_run=False) + assert d.action == "review_dispatch" + + +def test_inspect_pr_block_no_trigger(): + """Test inspect_pr() returns 'block' when trigger_reviews is False and no approval.""" + d = _inspect(_make_pr(), trigger_reviews=False) + assert d.action == "block" + assert "no OpenCode approval" in d.reason + + +# --------------------------------------------------------------------------- +# print_summary() +# --------------------------------------------------------------------------- + + +def test_print_summary(capsys: pytest.CaptureFixture) -> None: + """Test print_summary() emits per-PR lines and a JSON summary.""" + decisions = [ + Decision(pr=1, action="auto_merge", reason="approved"), + Decision(pr=2, action="skip", reason="draft"), + Decision(pr=3, action="auto_merge", reason="approved"), + ] + print_summary(decisions, dry_run=True, base_branch="main", project_flow="default") + out = capsys.readouterr().out + assert "PR #1" in out + assert "PR #2" in out + summary = json.loads(out.splitlines()[-1]) + assert summary["inspected"] == 3 + assert summary["counts"]["auto_merge"] == 2 + assert summary["counts"]["skip"] == 1 + assert summary["base_branch"] == "main" + assert summary["dry_run"] is True + + +def test_print_summary_empty(capsys: pytest.CaptureFixture) -> None: + """Test print_summary() works with an empty decision list.""" + print_summary([], dry_run=False, base_branch="main", project_flow="flow") + out = capsys.readouterr().out + summary = json.loads(out.strip()) + assert summary["inspected"] == 0 + assert summary["counts"] == {} + + +# --------------------------------------------------------------------------- +# self_test() +# --------------------------------------------------------------------------- + + +def test_self_test(capsys: pytest.CaptureFixture) -> None: + """Test self_test() runs without raising and prints a success message.""" + self_test() + assert "self-test passed" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# parse_args() +# --------------------------------------------------------------------------- + + +def test_parse_args_defaults(monkeypatch: pytest.MonkeyPatch) -> None: + """Test parse_args() sets default values when no arguments are provided.""" + monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) + monkeypatch.delenv("DEFAULT_BRANCH", raising=False) + monkeypatch.delenv("PROJECT_FLOW", raising=False) + args = parse_args([]) + assert args.max_prs == 100 + assert not args.dry_run + assert args.trigger_reviews is True + assert args.enable_auto_merge is True + assert not args.self_test + + +def test_parse_args_custom() -> None: + """Test parse_args() accepts and returns custom values.""" + args = parse_args( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--max-prs", + "25", + "--dry-run", + "--review-workflow", + "My Workflow", + "--self-test", + ] + ) + assert args.repo == "owner/repo" + assert args.base_branch == "main" + assert args.max_prs == 25 + assert args.dry_run + assert args.review_workflow == "My Workflow" + assert args.self_test + + +def test_parse_args_no_trigger_reviews() -> None: + """Test parse_args() handles --no-trigger-reviews flag.""" + args = parse_args(["--no-trigger-reviews"]) + assert args.trigger_reviews is False + + +def test_parse_args_no_auto_merge() -> None: + """Test parse_args() handles --no-enable-auto-merge flag.""" + args = parse_args(["--no-enable-auto-merge"]) + assert args.enable_auto_merge is False + + +# --------------------------------------------------------------------------- +# main() +# --------------------------------------------------------------------------- + + +def test_main_self_test() -> None: + """Test main() runs the self-test and returns 0 when --self-test is given.""" + assert main(["--self-test"]) == 0 + + +def test_main_missing_repo(monkeypatch: pytest.MonkeyPatch) -> None: + """Test main() raises SystemExit when --repo is not supplied.""" + monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) + monkeypatch.delenv("DEFAULT_BRANCH", raising=False) + monkeypatch.delenv("PROJECT_FLOW", raising=False) + with pytest.raises(SystemExit): + main([]) + + +def test_main_missing_base_branch(monkeypatch: pytest.MonkeyPatch) -> None: + """Test main() raises SystemExit when --base-branch is not supplied.""" + monkeypatch.delenv("DEFAULT_BRANCH", raising=False) + monkeypatch.delenv("PROJECT_FLOW", raising=False) + with pytest.raises(SystemExit): + main(["--repo", "owner/repo"]) + + +def test_main_missing_project_flow(monkeypatch: pytest.MonkeyPatch) -> None: + """Test main() raises SystemExit when --project-flow is not supplied.""" + monkeypatch.delenv("PROJECT_FLOW", raising=False) + with pytest.raises(SystemExit): + main(["--repo", "owner/repo", "--base-branch", "main"]) + + +def test_main_normal_run() -> None: + """Test main() fetches PRs and prints summary when all required args are given.""" + with patch("pr_review_merge_scheduler.fetch_open_prs") as mock_fetch: + mock_fetch.return_value = [] + result = main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--project-flow", + "default", + ] + ) + assert result == 0 + mock_fetch.assert_called_once_with("owner/repo", 100) + + +def test_main_normal_run_with_prs() -> None: + """Test main() processes multiple PRs and returns 0.""" + sample_pr = _make_pr() + with patch("pr_review_merge_scheduler.fetch_open_prs") as mock_fetch: + mock_fetch.return_value = [sample_pr] + result = main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--project-flow", + "default", + "--dry-run", + ] + ) + assert result == 0 From 7bf90a65878d80495cc58ca0a58a32010255a77a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:51:00 +0000 Subject: [PATCH 3/3] Fix weak assertion in test_run_passes_stdin --- ...rge_scheduler.cpython-312-pytest-9.1.1.pyc | Bin 86969 -> 85949 bytes tests/test_pr_review_merge_scheduler.py | 5 ++--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/__pycache__/test_pr_review_merge_scheduler.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_pr_review_merge_scheduler.cpython-312-pytest-9.1.1.pyc index 1eac6bb4bca6029e842187abaed384d499703ec9..19e7e78a2139f6b25c375e78809708a00cecc2dc 100644 GIT binary patch delta 7216 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 delta 7993 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