Ticket #12486: patchbot-scripts-5.0.patch
File patchbot-scripts-5.0.patch, 81.1 KB (added by , 9 years ago) |
---|
-
new file patchbot/README.txt
# HG changeset patch # User Robert Bradshaw <robertwb@math.washington.edu> # Date 1328866496 28800 # Node ID 49780561b45d9883684a9c74eab707761414c415 # Parent 7d84a793c1e5aca967f3c22a19ea5c10a9968367 trac #12486: add patchbot to Sage. Apply to scripts repo. diff --git a/patchbot/README.txt b/patchbot/README.txt new file mode 100644
- + 1 The patchbot only needs a Sage install and is started with 2 3 python patchbot.py [options] 4 5 Type --help for a list of options, though most configuration is done via an 6 optional JSON config file. This is what is invoked by sage --patchbot [...] 7 8 The server needs a Python with Flask and mongod installed. Installing numpy 9 and PIL will allow multi-colored blurbs. Start a monitoring loop with 10 11 python run_server.py 12 13 Currently, the server is set up to run on port 21100, communicating with 14 a mongod instance running on 21001. -
new file patchbot/db.py
diff --git a/patchbot/db.py b/patchbot/db.py new file mode 100644
- + 1 import os 2 3 # mongod --port=21000 --dbpath=data 4 import pymongo, gridfs 5 from pymongo import Connection 6 mongo_port = 21001 7 8 mongodb = Connection(port=mongo_port).buildbot 9 tickets = mongodb.tickets 10 tickets.ensure_index('id', unique=True) 11 tickets.ensure_index('status') 12 tickets.ensure_index('authors') 13 tickets.ensure_index('participants') 14 tickets.ensure_index('reports.base') 15 tickets.ensure_index('reports.machine') 16 tickets.ensure_index('reports.time') 17 18 logs = gridfs.GridFS(mongodb, 'logs') 19 20 def lookup_ticket(ticket_id): 21 return tickets.find_one({'id': ticket_id}) 22 23 def save_ticket(ticket_data): 24 old = lookup_ticket(ticket_data['id']) 25 if old: 26 old.update(ticket_data) 27 ticket_data = old 28 tickets.save(ticket_data) -
new file patchbot/http_post_file.py
diff --git a/patchbot/http_post_file.py b/patchbot/http_post_file.py new file mode 100644
- + 1 # http://code.activestate.com/recipes/146306-http-client-to-post-using-multipartform-data/ 2 3 import httplib, mimetypes, mimetools, urllib2 4 5 def post_multipart(url, fields, files): 6 """ 7 Post fields and files to an http host as multipart/form-data. 8 fields is a sequence of (name, value) elements for regular form fields. 9 files is a sequence of (name, filename, value) elements for data to be uploaded as files 10 Return the server's response page. 11 """ 12 content_type, body = encode_multipart_formdata(fields, files) 13 headers = {'Content-Type': content_type, 14 'Content-Length': str(len(body))} 15 r = urllib2.Request(url, body, headers) 16 return urllib2.urlopen(r).read() 17 18 def encode_multipart_formdata(fields, files): 19 """ 20 fields is a sequence of (name, value) elements for regular form fields. 21 files is a sequence of (name, filename, value) elements for data to be uploaded as files 22 Return (content_type, body) ready for httplib.HTTP instance 23 """ 24 BOUNDARY = mimetools.choose_boundary() 25 CRLF = '\r\n' 26 L = [] 27 if isinstance(fields, dict): 28 fields = fields.items() 29 for (key, value) in fields: 30 L.append('--' + BOUNDARY) 31 L.append('Content-Disposition: form-data; name="%s"' % key) 32 L.append('') 33 L.append(value) 34 for (key, filename, value) in files: 35 L.append('--' + BOUNDARY) 36 L.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename)) 37 L.append('Content-Type: %s' % get_content_type(filename)) 38 L.append('') 39 L.append(value) 40 L.append('--' + BOUNDARY + '--') 41 L.append('') 42 body = CRLF.join(L) 43 content_type = 'multipart/form-data; boundary=%s' % BOUNDARY 44 return content_type, body 45 46 def get_content_type(filename): 47 return mimetypes.guess_type(filename)[0] or 'application/octet-stream' -
new file patchbot/images/blue-blob.png
diff --git a/patchbot/images/blue-blob.png b/patchbot/images/blue-blob.png new file mode 100644 index 0000000000000000000000000000000000000000..aea190743765fae95f15fc364bc0cf01d226408e GIT binary patch literal 2654 zc$@)V3ZeChP)<h;3K|Lk000e1NJLTq001xm001xu1^@s6R|5Hm000UlNkl<ZXa&`p zzmHu<702hkAB(ebgXF|T>L?9GDzMi<DWD1nL`(rDMj#Ook`*LN6#ffbAT8Pzi4blr zo5pSsmXKJBfNiBC1r17_!hn<pg1!6h9loD)&zYTj-+OCA79@@|d*=6izvrBpd+)q$ za?a`hS!TxpzS+9AmD=`|^r`8~X^~z^^X3K3`)Q?(l$!mN@<*!wP~|_X{C>)ld-=h4 z9~{ft|EUKI^t62=rOE55X-+E%Pb9MBk<fifWFqr0b=~c>m|XuPWSW1MKy&8p*V24? zIW?0nC^<lZ3<68Q*AZ|cgz!PPmzr)n-}~O3VE458sR=Z%?!K7zPh3s&bl$8Q-~k+Y zti6Df^sE9Nr0V^)Z~{}br!L=0j~`#j@BQF|V<pg>edo<IoBm9|HhQqKd<}d72?yN* z-1A4FFaiS3E`%86X-9rP9pp>-{&%h&H9Xp$=z-?*Ki?6cw+uLz4r`+UK6f(Yv3d|- zg}KVB<N%8!fOhb89wLioH~-<{kNSS={~k#|;BN@=HwWO2fItHj@Ik_~>d>)qBXq{= zmrxS;nUWC-G6D~&EZyXr`R=zbjEC3dwFHg?4~bUEEG~_u0UqA56(~byg%|;WGyyDm z3m<6*Z^TqL>y9L`l0b9r?VU84Dr}}bD1ea`fK>uK<?uiO?w}VR3&=kVo;td&6@Z@= zxJ?I78Q@AYfyN^iMvyXMYr|=YK)+!pzc>sVCy^ZV0KC@$vIR6r`Gvq62^E(J09G<{ z0sv1ULK(2(0rEzokokW8{l%q?Js@C5WjFtm9!?wbfF^T*;fVlm7LPV*z%Kskps~18 z2_MR}IDryID&q+fM5buBJYfWp1Iq2bjQW}U?uEe-tJN!M=U26|AC-WpKn8d_b?}N( z`%ee{H)-XAFyw8l_cH_3dChBmRL2;`K4K0f=8XP?Ec4i!GHTlj-%yGN`X}t<*N1WM zAX?XR*D)yz9mwiv1MY+^WWob({qTla9ROGsPk174;Ui0%H=gS?b(Y%0J<+yb%J(kb zVHQO6T$=8(=&DdBHFMI;q&Z~*yoHc}7w~GwYDPpltQ(eGr3Za9KFW9_!f_k{;Miz{ z^H9tK&DJ|xTKm3%U?PB^!}<k40M#Ooh<WLH5hEs|MPm#kG!~bH^-W{t$^&8b*)uYn zJTaAyJ(1-J^Hh5-zx#&0%?gpE)mtTQHdj{w!urK4>duCJ^p{>Qfp>ydF9Q_f>+*Gf zpiuS{o(M04fxXaU(pzvZjH2Q7-bz@Cgg``zCvK24f__BWLbUmU_2LHH>z7DGJsM(l zS^0V%JBnp6TVHL2F^9>b5#C-V&DQRgdYo43J5nFO%WHw!XfNlxms!jROBWyks<Jxa z7%@H|#Q1uw=j(axh!zRMr*@wDv*vX3>Mw<x(ar1?GDXbF)~QtWAOWq3%qq&8RnRON zI>3wovzKf?IVmGUU+=d>BIc8>y<kRE9Z|8bFF=WA>0T~i%$gN$;tj?DHkyK@^`~9a zgoVNrc47%&=b!-_`Dn9oPC7`$JhiU95Nn>)=c`0-<IPKftJeZ#orbl;Ht$o2ImwtD zFdG7Fpz8T_+B8ZVs^jTO7nACAf)u9`b7!BI(B=8i@RU{^hRr>05y&t2EJGFG><eqy zw7R58ncT+V5hX&UBOfAQtplgKPDOilrmsEJTpMuZ?U?m>A(3>Xa;&m9b28j~4{>{N zjKYEvPKzu87E&yTl}XA2Jif&82z(g9x-u7Il@3I}o(b-S)EI82(bdNai3RDwf=?~1 zjG*<PYad%luIR%6E7HcCtU;PO&;X0J#8Bn&YSGl07;fO{-X=(}Xe8@2`V(!ggCucn z<9MpFn9yNZ+<x$P)u~K6aY`ig+Du>GG&}XBzl~8f5@4;4xkeK0Gwz#g8C^PsB}RSi zLz|C_aeDv{vhnzj7q3nm2g}oMA{%i6!N^V%Vp+iKp(GuE%;GBm8z$-_Sp|PS;tzJ> z#;lhDR13$oM}Rb~__O!+Ace(8sl(bL&>d(_wb{Zb0U(Yu1S;W>SSN+tiVg=oZV~8o z-6uenydy;Tu*S^<8JhO`E6QG~R96Uim=G#>z%+G`V7U6uPl|}ramc(8ZalD<>aNlO z79w7zp7Xkd5nyUBP*z$+0xwwXd%GDR#<H};jMHaV{w=sxwC3)8YG?ZHyk~BX&TL5X zXu>EqC?DHA!tImqISuvKX`5U90LaEOw^NL|ih%X;dfUW|@p3VqO*r}!eX43*5L24u z2XE=UIzLoWkqNb6EW}53+|ig9XroR?A@AzEvllB@A!Kz?uE%hO`x?xLfo!iyhd^jR zDjLw<mb-^}@9Wj~KuWjEpcq_;2mscCOoU2J;zUh^`1cefAj$DLSHBG5YCfAcK&=B_ zwFj3)b33v`A;hjLRDNia69Oz!YxaSvEoOz!gfgD6_Y=LP0(5ZHX<;l@?tk0>cJD(T zVA6Ql`YCUkdatuSo}csla1U<e>nfNWe$;<%_qY1??XS%3om~f%y$1N}K-^03T^e}+ z+Z!bsMo4HDmKYcZT)+dIIufBa)^(ld?I9>n=58<F{n{7%$@}L(pKli17T;kFCM9o^ zD<2D5`1pbVicmRHdq2D18O>$us4mtmUhul`lwrXrop2rs4>FVd-sL;W+$w+re0@wp z!a%rH?4d<oY3eD6ER{9Z`k?_Ax-wFLHLp<*V5JD3d3P({JrB+zg@Dz3{P>C{-!CM> z`mtC5wR#60fXAD6T(*8rqRi(cVjUyqgo*)Gw!5Nd_tVD4m4e(e0|NPvKmI^gx@4^D zNekduFsxmb0SK_Lynzgo^sB}c&-wTQc3gJwwt2ft`Bz^aTr0w%Y+@&5syVx(+iCr% z!K)IR(>5x3FGsy?Vm57T+|V(b0GMD^@@Rn!T^ni1D<W)`*Ty}uxto9U)s;VXtR%pi z&AFW$T4(s<#?q<d;FnGyW<Z=kfH&YPNGySeq>`OH&)>{@U%6n@t@yK+0DYS?+c)%C z*pmqG2DS#Se5o6+Fd{G@Lb%Y59wqAR>+96b{NP(_!5hXW{mj~&{ppSX_P>!?r~o)Z z2_iO)lkj4#3TP_b`cbzkoB(gH?5B3~{R=C@rk?&t4@Af2?Dm^Ns9z~{#a*mDvU~}n z80Ff)k_S8%_heQlkM(Jj?!oV;`SX|Z@4j(O6;G6>BET%onV-Cvy4h7-g`6)W3K{FM zW-&k_fFxgQKvQ--+q{)#r>=~D#9;I~d1?YNf%e5~sm+(|wS&q5iRd-f73cv02myX` zFU>dqyT8<rAvK@f-qODZ=vF)He<h~6TMdbQ0l1JLKsoZ=Lw(`tE%@aB_J1V;QmuP3 zx~puGuKTz#v&%C7Zx!8oWL+Gz`9r;V>N8OPtulQ^|E<Dz$S0S70Umj<XBJb)mjD0& M07*qoM6N<$f(gJ5ZU6uP
-
new file patchbot/images/green-blob.png
diff --git a/patchbot/images/green-blob.png b/patchbot/images/green-blob.png new file mode 100644 index 0000000000000000000000000000000000000000..b610b8f543da98af17d74d5d7616082aa0d0c946 GIT binary patch literal 2617 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 b1?7JO^qZY>hVjBs00000NkvXXu0mjf2gVBz
-
new file patchbot/images/purple-blob.png
diff --git a/patchbot/images/purple-blob.png b/patchbot/images/purple-blob.png new file mode 100644 index 0000000000000000000000000000000000000000..ef2fac14668b358229709db09d381a526eb51b74 GIT binary patch literal 2731 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
-
new file patchbot/images/red-blob.png
diff --git a/patchbot/images/red-blob.png b/patchbot/images/red-blob.png new file mode 100644 index 0000000000000000000000000000000000000000..7acaa38454b2014f6f1a7f49788bc59aae6ce8c3 GIT binary patch literal 2851 zc$@(v3*7XHP)<h;3K|Lk000e1NJLTq001xm001xu1^@s6R|5Hm000W=Nkl<ZXa&_; ze{9`V74Lg}ZP&G1x3yhmwN0Tbbp*|b2?@;*u>@ry3$B_#voIDnCAbVTf}|vbVWRy* zQ>6~c)`SF8HZj|T5C$?ABk7hHO`L*-(si<7vtKQJWqm7qZTI|qKE3C1-?y(_m1*Kh z?>pz7d+#})&%NjSec$(9W0_1Q@Bdj6R|9-g*UF|ZemK6Sa$tOQQQxJ;MW-gJOV3X( zO?;dwoGOT=Q<aI43)dD5rW=d;&NUPt`%}f@o}-)IIeIm6$Ntk3An4oQ8{M$t*U9xu zPEA(D^7HayQ)muVR2x`3@6noqvxBW=2M#q>bblH$|2BcUcGa)F^|hhxwJ(3v91lPf zu$nv5*yJ>1BN8Q$aq}{mck=6t55KvtqT`hZ1`gW1`RaKSsNZ!%!~H#{+v~fNEd&)u zGthB>Az*=}%`r!SKw<${_9-C&o(lT|8<*_w`pPwJ1FgONnlz7>c@fz3bj_A8JvO+r z;8LcLyB>F?;(-UC2{uX~7Y+ipa^fkle;InBG?xDTk88H>e(+>hu60r06$!LGy8P)I zesX>rcib8Q>I9H9&YcfHo6STz5gHNkz~MLWI)#A9+fP(=?)br(ha*d8bmt`S<hPdX zS^3<^eFj`Q04)*-nMDG918zE+HH!cS1)drNE+f7)`U4Lx+xz`zhg+lT!~U!U<^pd- zSm!DuMD(ZPqO(62fhdW~m~TYPrar!TZW1{OuxwVLY*6mLYzS1q9cbAOq@9F;z6gKH zA>^$!Dj5VW!Q_P)Fwmwwcy}7?ztvXN`Q$9+G=spVj+!mEKl0Dr0*FNU03h%Lj$ob$ zZPy5#pf3Ui0jIEzeM*!7!Ba{wPXy3khyW$<dGOBWMQs=N8@p@2{p^;R7j_nb`d#%6 zk3aH{w+mC5!h8hg-G(5Ub2jkw6YP0_r?xjUlA@TN?oIDFZQF4YA`vxArX~?j#+}5a z1+nywU7x$LH*$+*elPbQ8E!8CcsdD0pkYf}387CyY~yGIK7=IWoNt6N$lI;Wi)xLz zajxOoA;cO&EXK6?J}=@I;<04&A5OPpI7>K3a@RBUYwPxmw$Q=H0%?Lq<E@c*8U^Gy znsyyNCT3EO@zj`{pjdjc&F^B;r?SL&bWM1YmW$f*U-w+H<(@r_YvJ<3aY@smbKCv# zBRE0o_DJAp3PPW2<M53WlmVgNfi@xn3%|(N82fB^k+${h!{%vNIa)4hw~4p?=FE01 z_aXvKzgXE+^YU0TLF=K6+U>k)P82C<g6D1-cuS<~27cRpfi$4>)3&}NWTmE97-OuE zgymFzV7l(5kD71$)v6{;lhB{{<@b^sJaD%odbY9M#~cep;OCG?1YjF@d&I!dC-qN| z4k-Q6li*tVd<@zV5>}p;pSZsIw+}a9sYC>-_KmLh<>Lmpz;<6Y`)Its8+f}`47>sH zpa4d|vCgys6nOZ<zSajMnCBt@d+?l(Q>cD%WIaqtoaIwCi065935b#lxWHqKL5(br zUBQE0WC(&1VECnxG@lJ);fX{6+XjSjMS?jQ*XgAvCaSEA;x~<quYuo-h>$g!iG1kr zjY<SyTgh2apB!TXX@J>}HjHt;{CElLKa9t`;VBpnZtyM1G2xCc>rbsVFx=TtbREE9 z3vEn&xBIlY<EBL<1Q-%=wt?4NJ3Z<kj>V%6Pl0{5%HE5sq4MHI{pm&;CN{zPp;qYx zz(e9%=f-&3)$U^PY&z#7i6dhrYFdvbtO~@lw&3rVggah(a-uo}X|}Ed>&!!2V6M~7 zk_2r0WswkYjM+)qd~ODPwkdWRwlO4;;<#&z-%ks7V&Tx#QuwZ#*tJ8T+<k$9#^s|% z0Ad9a^l@AoyH9Pq4s#kTg3y;jD~4|&;xfjfbCbfINZ@0lGI2#9`lJ-}=WFumG0%bH z8{Q?i@G^k#sgocGlq9GH8d}$z1FKyIV2Tm4lbQBnIi8JZCnk$x>HNv*4xbD&ZYOMF zs5K6U&GFi;BS->KZfG9)7#jO*6+p|+0UkK!2rR}-)DMW5-?Yu2O2pFi#Z%?^BL>7K znYIt!meBiQcrvgr1Dzg$p{I}>xU}<Jmp~tXx>fRS64tS<GglG<MwU6m#&{YgV^xU} z+VNCf!Js*fh=8*cAO@bACBWx(`3yAr_WVvg1wRQ)(C>B<rgoZjoZ{d-C0aJ&veAY@ z;f{aYP}pZ6ZG!3_iRia_rp-@HA^>3ez~QujV~iWbMvSuY!Z8Zhm$JEyq3_d3`o^L@ zu8$AbmmCuTfRTpNYar3L1kOh=;VGzeLK3h&31@Q+8)K3@o0lt`hE;^09~kFWl^$a^ z{@1eN9sx#XX(PhB1REx?g&-<XO2q~o>kKr@Czq|YjBBx7OvQF5ZMiWtLvoG%aoNHi zPSj`Oj@p7VOFx*bLVVqqsY5%wr|S&}<Mwfq7p6UY>0;wTi0z)p!&eMumDmv8MoE&S zJX!WZgg1b!O$g^<tOETCBpI$NID21z`ZMSXZ{F#3We1F<Y3&*k+RifpZEFO=lc)p= zfnsv_1OSF?0*8-@4SX0+xp)G03Rol`=9~CaV?ZZ^8_N%v%?-^va{ZESE@Vw-X%|?a zNhSx03i#EqUON=k7+1+Le$ZH+Mn*Y7R^X%BVJd;2*B|G+_W6o#D2e}C<*B<D{jTQK zv1T&DTpdl8jf5&6kgx|amJEH=Y!uT>L=m_^dlGIlxUuwwI+v}@0frN;Wrx4>eDV$~ zkubmH$eraKyl*QZw06g7CAHm05Y;PISz;!ftJyro;kqyu`$1mjXW4MBl}~6NTDPJD zLtZ?AH@3Zh@YFZT_A3d{hzK-;M-vk)eNzB37w~Y*lMcbd_IVJG7rwwki-^-!J_Fw^ z-@mV=_n=1)!xNzMg{JbhbV)4j0mB#G>kW{=W6b)}Boh*f_8H5q;d_fe1?RV%;qKGN zKHocvieu@Y-%-;BGfT1v3~U?dKk`KNRtsW5NrE8N4?x<u5h1XgHr9GSZN>;r*5{)S z6YW%}_2!2Q>$kh>w)WormwvdiB#Qt?x*z+X>z%FTomM(Z93=CJLn3BVhYj(3>4dfe zYNUAURX%FYbB*A62Rr`5YdU{+?};vSXNbQGMMoarxMa_@za0JK#|<Rq(;yl1PNN4c z^kuunv_s(zQdG~>h~euTeFK^cJbk?LBp<EV`%wG&S%2i1l>pY}g(L`0;2jYIF713= z6AA%S!gpnnf!~*kvppHiCwRVdU-;g#z1x2}_b)UvN=O;q%a5K<w!Zstc_(5iD7B8; z^_kRs($;rc*A50aCV&8D^KC8XM8UIkhIUkSUI{#=O+O3Km`gUa*KE1vsllCv`1lzu z9V;99a|w5!;>(D(+jaW5+g4Vz3-LR^TRZEvKJ!5CnrEy~<}9IbVtr?Q!@Wm`+iPDO z)$hd7(lH|88rlR&vHA~*aOXwBjSt~{Xx}$iwDq=J<==@3z~s)3y0teS8s1*>`dBl4 zB4UHH=n!<22zPztYo&+(e9!WZS2q6_f2ogRySZa!)2%0x8<xK`x}M*y+PIyB?L7Sk zS~mM=qt%JC7uJ^@c=M*p|Ly-u5JXf+<_pQxn(`Cls|yFxjivZ+mEz>o()^3~Zxwv& z!7mQRs`CdYu3ykMR9k%P-OrZv9DV5cRrzm~e*r*wR8+N>t;YZW002ovPDHLkV1i$O Be6Iii
-
new file patchbot/images/white-blob.png
diff --git a/patchbot/images/white-blob.png b/patchbot/images/white-blob.png new file mode 100644 index 0000000000000000000000000000000000000000..44032e2ce17c5aaaeb0b29b5b819acf67ad90031 GIT binary patch literal 2045 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 bemne6xb|riB^Ycf00000NkvXXu0mjfWt7fB
-
new file patchbot/images/yellow-blob.png
diff --git a/patchbot/images/yellow-blob.png b/patchbot/images/yellow-blob.png new file mode 100644 index 0000000000000000000000000000000000000000..d212160fa672cf5da151313e75f74b6a2d697d42 GIT binary patch literal 3012 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 GLSTaGkmXYV
-
new file patchbot/patchbot.py
diff --git a/patchbot/patchbot.py b/patchbot/patchbot.py new file mode 100755
- + 1 #!/usr/bin/env python 2 3 #################################################################### 4 # 5 # This is the main script for the patchbot. It pulls patches from 6 # trac, applies them, and publishes the results of the tests to a 7 # server running serve.py. Configuration is primarily done via an 8 # optional conf.txt file passed in as a command line argument. 9 # 10 # Author: Robert Bradshaw <robertwb@gmail.com> 11 # 12 # Copyright 2010-11 (C) Google, Inc. 13 # 14 # Distributed under the terms of the GNU General Public License (GPL) 15 # as published by the Free Software Foundation; either version 2 of 16 # the License, or (at your option) any later version. 17 # http://www.gnu.org/licenses/ 18 #################################################################### 19 20 21 import signal 22 import getpass, platform 23 import random, re, os, shutil, sys, subprocess, time, traceback 24 import bz2, urllib2, urllib, json 25 from optparse import OptionParser 26 27 from http_post_file import post_multipart 28 29 from trac import scrape, pull_from_trac 30 from util import now_str as datetime, parse_datetime, prune_pending, do_or_die, get_base, compare_version, current_reports 31 32 def filter_on_authors(tickets, authors): 33 if authors is not None: 34 authors = set(authors) 35 for ticket in tickets: 36 if authors is None or set(ticket['authors']).issubset(authors): 37 yield ticket 38 39 def contains_any(key, values): 40 clauses = [{'key': value} for value in values] 41 return {'$or': clauses} 42 43 def no_unicode(s): 44 return s.encode('ascii', 'replace').replace(u'\ufffd', '?') 45 46 def get_ticket(server, return_all=False, **conf): 47 query = "raw&status=open&todo" 48 if 'trusted_authors' in conf: 49 query += "&authors=" + urllib.quote_plus(no_unicode(':'.join(conf['trusted_authors'])), safe=':') 50 try: 51 handle = urllib2.urlopen(server + "/ticket/?" + query) 52 all = json.load(handle) 53 handle.close() 54 except: 55 traceback.print_exc() 56 return 57 if 'trusted_authors' in conf: 58 all = filter_on_authors(all, conf['trusted_authors']) 59 all = filter(lambda x: x[0], ((rate_ticket(t, **conf), t) for t in all)) 60 all.sort() 61 if return_all: 62 return all 63 if all: 64 return all[-1] 65 66 def lookup_ticket(server, id): 67 url = server + "/ticket/?" + urllib.urlencode({'raw': True, 'query': json.dumps({'id': id})}) 68 res = json.load(urllib2.urlopen(url)) 69 if res: 70 return res[0] 71 else: 72 return scrape(id) 73 74 def compare_machines(a, b, machine_match=None): 75 if isinstance(a, dict) or isinstance(b, dict): 76 # old format, remove 77 return (1,) 78 else: 79 if machine_match is not None: 80 a = a[:machine_match] 81 b = b[:machine_match] 82 diff = [x != y for x, y in zip(a, b)] 83 if len(a) != len(b): 84 diff.append(1) 85 return diff 86 87 def rate_ticket(ticket, **conf): 88 rating = 0 89 if ticket['spkgs']: 90 return # can't handle these yet 91 elif not ticket['patches']: 92 return # nothing to do 93 for dep in ticket['depends_on']: 94 if isinstance(dep, basestring) and '.' in dep: 95 if compare_version(conf['base'], dep) < 0: 96 # Depends on a newer version of Sage than we're running. 97 return None 98 for author in ticket['authors']: 99 if author not in conf['trusted_authors']: 100 return 101 rating += conf['bonus'].get(author, 0) 102 for participant in ticket['participants']: 103 rating += conf['bonus'].get(participant, 0) # doubled for authors 104 rating += len(ticket['participants']) 105 # TODO: remove condition 106 if 'component' in ticket: 107 rating += conf['bonus'].get(ticket['component'], 0) 108 rating += conf['bonus'].get(ticket['status'], 0) 109 rating += conf['bonus'].get(ticket['priority'], 0) 110 rating += conf['bonus'].get(str(ticket['id']), 0) 111 redundancy = (100,) 112 prune_pending(ticket) 113 if not ticket.get('retry'): 114 for reports in current_reports(ticket, base=conf['base']): 115 redundancy = min(redundancy, compare_machines(reports['machine'], conf['machine'], conf['machine_match'])) 116 if not redundancy[-1]: 117 return # already did this one 118 return redundancy, rating, -int(ticket['id']) 119 120 def report_ticket(server, ticket, status, base, machine, user, log, plugins=[]): 121 print ticket['id'], status 122 report = { 123 'status': status, 124 'patches': ticket['patches'], 125 'deps': ticket['depends_on'], 126 'spkgs': ticket['spkgs'], 127 'base': base, 128 'user': user, 129 'machine': machine, 130 'time': datetime(), 131 'plugins': plugins, 132 } 133 fields = {'report': json.dumps(report)} 134 if status != 'Pending': 135 files = [('log', 'log', bz2.compress(open(log).read()))] 136 else: 137 files = [] 138 try: 139 print post_multipart("%s/report/%s" % (server, ticket['id']), fields, files) 140 except: 141 traceback.print_exc() 142 143 class TimeOut(Exception): 144 pass 145 146 def alarm_handler(signum, frame): 147 raise Alarm 148 149 class Tee: 150 def __init__(self, filepath, time=False, timeout=60*60*24): 151 self.filepath = filepath 152 self.time = time 153 self.timeout = timeout 154 155 def __enter__(self): 156 self._saved = os.dup(sys.stdout.fileno()), os.dup(sys.stderr.fileno()) 157 self.tee = subprocess.Popen(["tee", self.filepath], stdin=subprocess.PIPE) 158 os.dup2(self.tee.stdin.fileno(), sys.stdout.fileno()) 159 os.dup2(self.tee.stdin.fileno(), sys.stderr.fileno()) 160 if self.time: 161 print datetime() 162 self.start_time = time.time() 163 164 def __exit__(self, exc_type, exc_val, exc_tb): 165 if exc_type is not None: 166 traceback.print_exc() 167 if self.time: 168 print datetime() 169 print int(time.time() - self.start_time), "seconds" 170 self.tee.stdin.close() 171 time.sleep(1) 172 os.dup2(self._saved[0], sys.stdout.fileno()) 173 os.dup2(self._saved[1], sys.stderr.fileno()) 174 time.sleep(1) 175 try: 176 signal.signal(signal.SIGALRM, alarm_handler) 177 signal.alarm(self.timeout) 178 self.tee.wait() 179 signal.alarm(0) 180 except TimeOut: 181 traceback.print_exc() 182 raise 183 return False 184 185 186 class Timer: 187 def __init__(self): 188 self._starts = {} 189 self._history = [] 190 self.start() 191 def start(self, label=None): 192 self._last_activity = self._starts[label] = time.time() 193 def finish(self, label=None): 194 try: 195 elapsed = time.time() - self._starts[label] 196 except KeyError: 197 elapsed = time.time() - self._last_activity 198 self._last_activity = time.time() 199 self.print_time(label, elapsed) 200 self._history.append((label, elapsed)) 201 def print_time(self, label, elapsed): 202 print label, '--', int(elapsed), 'seconds' 203 def print_all(self): 204 for label, elapsed in self._history: 205 self.print_time(label, elapsed) 206 207 # The sage test scripts could really use some cleanup... 208 all_test_dirs = ["doc/common", "doc/en", "doc/fr", "sage"] 209 210 status = { 211 'started': 'ApplyFailed', 212 'applied': 'BuildFailed', 213 'built' : 'TestsFailed', 214 'tested' : 'TestsPassed', 215 'failed_plugin' : 'PluginFailed', 216 } 217 218 def plugin_boundary(name, end=False): 219 if end: 220 name = 'end ' + name 221 return ' '.join(('='*10, name, '='*10)) 222 223 224 def test_a_ticket(sage_root, server, ticket=None, nodocs=False): 225 base = get_base(sage_root) 226 if ticket is None: 227 ticket = get_ticket(base=base, server=server, **conf) 228 else: 229 ticket = None, scrape(int(ticket)) 230 if not ticket: 231 print "No more tickets." 232 if random.random() < 0.01: 233 cleanup(sage_root, server) 234 time.sleep(conf['idle']) 235 return 236 rating, ticket = ticket 237 print "\n" * 2 238 print "=" * 30, ticket['id'], "=" * 30 239 print ticket['title'] 240 print "score", rating 241 print "\n" * 2 242 log_dir = sage_root + "/logs" 243 if not os.path.exists(log_dir): 244 os.mkdir(log_dir) 245 log = '%s/%s-log.txt' % (log_dir, ticket['id']) 246 report_ticket(server, ticket, status='Pending', base=base, machine=conf['machine'], user=conf['user'], log=None) 247 plugins_results = [] 248 try: 249 with Tee(log, time=True, timeout=conf['timeout']): 250 t = Timer() 251 start_time = time.time() 252 253 state = 'started' 254 os.environ['MAKE'] = "make -j%s" % conf['parallelism'] 255 os.environ['SAGE_ROOT'] = sage_root 256 # TODO: Ensure that sage-main is pristine. 257 pull_from_trac(sage_root, ticket['id'], force=True) 258 t.finish("Apply") 259 state = 'applied' 260 261 do_or_die('$SAGE_ROOT/sage -b %s' % ticket['id']) 262 t.finish("Build") 263 state = 'built' 264 265 working_dir = "%s/devel/sage-%s" % (sage_root, ticket['id']) 266 # Only the ones on this ticket. 267 patches = os.popen2('hg --cwd %s qapplied' % working_dir)[1].read().strip().split('\n')[-len(ticket['patches']):] 268 kwds = { 269 "original_dir": "%s/devel/sage-0" % sage_root, 270 "patched_dir": working_dir, 271 "patches": ["%s/devel/sage-%s/.hg/patches/%s" % (sage_root, ticket['id'], p) for p in patches if p], 272 } 273 for name, plugin in conf['plugins']: 274 try: 275 print plugin_boundary(name) 276 plugin(ticket, **kwds) 277 passed = True 278 except Exception: 279 traceback.print_exc() 280 passed = False 281 finally: 282 t.finish(name) 283 print plugin_boundary(name, end=True) 284 plugins_results.append((name, passed)) 285 286 test_dirs = ["$SAGE_ROOT/devel/sage-%s/%s" % (ticket['id'], dir) for dir in all_test_dirs] 287 if conf['parallelism'] > 1: 288 test_cmd = "-tp %s" % conf['parallelism'] 289 else: 290 test_cmd = "-t" 291 do_or_die("$SAGE_ROOT/sage %s -sagenb %s" % (test_cmd, ' '.join(test_dirs))) 292 #do_or_die("$SAGE_ROOT/sage -t $SAGE_ROOT/devel/sage-%s/sage/rings/integer.pyx" % ticket['id']) 293 #do_or_die('sage -testall') 294 t.finish("Tests") 295 state = 'tested' 296 297 if not all(passed for name, passed in plugins_results): 298 state = 'failed_plugin' 299 300 print 301 t.print_all() 302 except Exception: 303 traceback.print_exc() 304 report_ticket(server, ticket, status=status[state], base=base, machine=conf['machine'], user=conf['user'], log=log, plugins=plugins_results) 305 return status[state] 306 307 def cleanup(sage_root, server): 308 print "Looking up closed tickets." 309 closed_list = urllib2.urlopen(server + "?status=closed").read() 310 closed = set(m.groups()[0] for m in re.finditer(r"/ticket/(\d+)/", closed_list)) 311 for branch in os.listdir(os.path.join(sage_root, "devel")): 312 if branch[:5] == "sage-": 313 if branch[5:] in closed: 314 to_delete = os.path.join(sage_root, "devel", branch) 315 print "Deleting closed ticket:", to_delete 316 shutil.rmtree(to_delete) 317 print "Done cleaning up." 318 319 def default_trusted_authors(server): 320 handle = urllib2.urlopen(server + "/trusted/") 321 try: 322 return json.load(handle).keys() 323 finally: 324 handle.close() 325 326 def machine_data(): 327 system, node, release, version, arch = os.uname() 328 if system.lower() == "linux": 329 dist_name, dist_version, dist_id = platform.linux_distribution() 330 if dist_name: 331 return [dist_name, dist_version, arch, release, node] 332 return [system, arch, release, node] 333 334 def parse_time_of_day(s): 335 def parse_interval(ss): 336 ss = ss.strip() 337 if '-' in ss: 338 start, end = ss.split('-') 339 return float(start), float(end) 340 else: 341 return float(ss), float(ss) + 1 342 return [parse_interval(ss) for ss in s.split(',')] 343 344 def check_time_of_day(hours): 345 from datetime import datetime 346 now = datetime.now() 347 hour = now.hour + now.minute / 60. 348 for start, end in parse_time_of_day(hours): 349 if start < end: 350 if start <= hour <= end: 351 return True 352 elif hour <= end or start <= hour: 353 return True 354 return False 355 356 def get_conf(path, server, **overrides): 357 if path is None: 358 unicode_conf = {} 359 else: 360 unicode_conf = json.load(open(path)) 361 # defaults 362 conf = { 363 "idle": 300, 364 "time_of_day": "0-0", # midnight-midnight 365 "parallelism": 3, 366 "timeout": 3 * 60 * 60, 367 "plugins": ["plugins.commit_messages", 368 "plugins.coverage", 369 "plugins.trailing_whitespace", 370 # "plugins.docbuild" 371 ], 372 "bonus": {}, 373 "machine": machine_data(), 374 "machine_match": 3, 375 "user": getpass.getuser(), 376 } 377 default_bonus = { 378 "needs_review": 1000, 379 "positive_review": 500, 380 "blocker": 100, 381 "critical": 50, 382 } 383 for key, value in unicode_conf.items(): 384 conf[str(key)] = value 385 for key, value in default_bonus.items(): 386 if key not in conf['bonus']: 387 conf['bonus'][key] = value 388 conf.update(overrides) 389 if "trusted_authors" not in conf: 390 conf["trusted_authors"] = default_trusted_authors(server) 391 392 def locate_plugin(name): 393 ix = name.rindex('.') 394 module = name[:ix] 395 name = name[ix+1:] 396 plugin = getattr(__import__(module, fromlist=[name]), name) 397 assert callable(plugin) 398 return plugin 399 conf["plugins"] = [(name, locate_plugin(name)) for name in conf["plugins"]] 400 return conf 401 402 def main(args): 403 global conf 404 405 # Most configuration is done in the config file, which is reread between 406 # each ticket for live configuration of the patchbot. 407 parser = OptionParser() 408 parser.add_option("--config", dest="config") 409 parser.add_option("--sage-root", dest="sage_root", default=os.environ.get('SAGE_ROOT')) 410 parser.add_option("--server", dest="server", default="http://patchbot.sagemath.org/") 411 parser.add_option("--count", dest="count", default=1000000) 412 parser.add_option("--ticket", dest="ticket", default=None) 413 parser.add_option("--list", dest="list", default=False) 414 parser.add_option("--skip-base", dest="skip_base", default=False) 415 (options, args) = parser.parse_args(args) 416 417 conf_path = options.config and os.path.abspath(options.config) 418 if options.ticket: 419 tickets = [int(t) for t in options.ticket.split(',')] 420 count = len(tickets) 421 else: 422 tickets = None 423 count = int(options.count) 424 425 conf = get_conf(conf_path, options.server) 426 if options.list: 427 for score, ticket in get_ticket(base=get_base(options.sage_root), server=options.server, return_all=True, **conf): 428 print score, ticket['id'], ticket['title'] 429 print ticket 430 print 431 sys.exit(0) 432 433 print "WARNING: Assuming sage-main is pristine." 434 if options.sage_root == os.environ.get('SAGE_ROOT'): 435 print "WARNING: Do not use this copy of sage while the patchbot is running." 436 437 if not options.skip_base: 438 clean = lookup_ticket(options.server, 0) 439 def good(report): 440 return report['machine'] == conf['machine'] and report['status'] == 'TestsPassed' 441 if not any(good(report) for report in current_reports(clean, base=get_base(options.sage_root))): 442 res = test_a_ticket(ticket=0, sage_root=options.sage_root, server=options.server) 443 if res != 'TestsPassed': 444 print "\n\n" 445 while True: 446 print "Failing tests in your install: %s. Continue anyways? [y/N] " % res 447 ans = sys.stdin.readline().lower().strip() 448 if ans == '' or ans[0] == 'n': 449 sys.exit(1) 450 elif ans[0] == 'y': 451 break 452 453 for _ in range(count): 454 if tickets: 455 ticket = tickets.pop(0) 456 else: 457 ticket = None 458 conf = get_conf(conf_path, options.server) 459 if check_time_of_day(conf['time_of_day']): 460 test_a_ticket(ticket=ticket, sage_root=options.sage_root, server=options.server) 461 else: 462 print "Idle." 463 time.sleep(conf['idle']) 464 465 if __name__ == '__main__': 466 # allow this script to serve as a single entry point for bots and the server 467 args = list(sys.argv) 468 if len(args) > 1 and args[1] == '--serve': 469 del args[1] 470 from serve import main 471 main(args) -
new file patchbot/plugins.py
diff --git a/patchbot/plugins.py b/patchbot/plugins.py new file mode 100644
- + 1 """ 2 A plugin is any callable. 3 4 It is called after the ticket has been successfully applied and built, 5 but before tests are run. It should print out any analysis to stdout, 6 raising an exception if anything went wrong. 7 8 The parameters are as follows: 9 10 ticket -- a dictionary of all the ticket informaton 11 original_dir -- pristine sage-main directory 12 patched_dir -- patched sage-branch directory for this ticket 13 patchs -- a list of absolute paths to the patch files for this ticket 14 15 It is recommended that a plugin ignore extra keywords to be 16 compatible with future options. 17 """ 18 19 import re, os, sys 20 21 from trac import do_or_die 22 23 24 def coverage(ticket, **kwds): 25 do_or_die('$SAGE_ROOT/sage -coverageall') 26 27 def docbuild(ticket, **kwds): 28 do_or_die('$SAGE_ROOT/sage -docbuild --jsmath reference html') 29 30 def trailing_whitespace(ticket, patches, **kwds): 31 ignore_empty = True 32 bad_lines = 0 33 trailing = re.compile("\\+.*\\s+$") 34 for patch_path in patches: 35 patch = os.path.basename(patch_path) 36 print patch 37 for ix, line in enumerate(open(patch_path)): 38 line = line.strip("\n") 39 m = trailing.match(line) 40 if m: 41 print " %s:%s %s$" % (patch, ix+1, line) 42 if line.strip() == '+' and ignore_empty: 43 pass 44 else: 45 bad_lines += 1 46 msg = "Trailing whitespace inserted on %s %slines." % (bad_lines, "non-empty " if ignore_empty else "") 47 print msg 48 if bad_lines > 0: 49 raise ValueError(msg) 50 51 def commit_messages(ticket, patches, **kwds): 52 for patch_path in patches: 53 patch = os.path.basename(patch_path) 54 print "Looking at", patch 55 header = [] 56 for line in open(patch_path): 57 if line.startswith('diff '): 58 break 59 header.append(line) 60 else: 61 print ''.join(header[:10]) 62 raise ValueError("Not a valid patch file: " + patch) 63 print ''.join(header) 64 if header[0].strip() != "# HG changeset patch": 65 raise ValueError("Not a mercurial patch file: " + patch) 66 for line in header: 67 if not line.startswith('# '): 68 # First description line 69 if line.startswith('[mq]'): 70 raise ValueError("Mercurial queue boilerplate") 71 elif not re.search(r"\b%s\b" % ticket['id'], line): 72 print "Ticket number not in first line of comments: " + patch 73 break 74 else: 75 raise ValueError("No patch comments:" + patch) 76 print 77 print "All patches good." 78 79 if __name__ == '__main__': 80 plugin = globals()[sys.argv[1]] 81 plugin(-1, patches=sys.argv[2:]) -
new file patchbot/run_server.py
diff --git a/patchbot/run_server.py b/patchbot/run_server.py new file mode 100755
- + 1 #!/usr/bin/env python 2 3 import os, signal, subprocess, sys, time, traceback, urllib2 4 5 if not hasattr(subprocess.Popen, 'send_signal'): 6 def send_signal(self, sig): 7 os.kill(self.pid, sig) 8 subprocess.Popen.send_signal = send_signal 9 10 DATABASE = "../data" 11 12 # The server hangs while connecting to trac, so we poll it and 13 # restart if needed. 14 15 HTTP_TIMEOUT = 60 16 POLL_INTERVAL = 180 17 KILL_WAIT = 5 18 19 p = None 20 try: 21 # Start mongodb 22 mongo_process = subprocess.Popen(["mongod", "--port=21001", "--dbpath=" + DATABASE], stderr=subprocess.STDOUT) 23 24 # Run the server 25 while True: 26 27 if p is None or p.poll() is not None: 28 # The subprocess died. 29 restart = True 30 else: 31 try: 32 print "Testing url..." 33 urllib2.urlopen("http://patchbot.sagemath.org/", timeout=HTTP_TIMEOUT) 34 print " ...good" 35 restart = False 36 except urllib2.URLError, e: 37 print " ...bad", e 38 restart = True 39 40 if restart: 41 if p is not None and p.poll() is None: 42 print "SIGTERM" 43 p.send_signal(signal.SIGTERM) 44 time.sleep(KILL_WAIT) 45 if p.poll() is None: 46 print "SIGKILL" 47 p.kill() 48 time.sleep(KILL_WAIT) 49 50 print "Starting server..." 51 base = open("base.txt").read().strip() 52 p = subprocess.Popen([sys.executable, "serve.py", "--base=" + base, "--port=21100"]) 53 open("server.pid", "w").write(str(p.pid)) 54 print " ...done." 55 time.sleep(POLL_INTERVAL) 56 57 finally: 58 traceback.print_exc() 59 mongo_process.send_signal(signal.SIGTERM) 60 if p is not None and p.poll() is None: 61 p.kill() 62 -
new file patchbot/serve.py
diff --git a/patchbot/serve.py b/patchbot/serve.py new file mode 100644
- + 1 import os, sys, bz2, json, traceback, re, collections 2 from cStringIO import StringIO 3 from optparse import OptionParser 4 from flask import Flask, render_template, make_response, request, Response 5 import pymongo 6 import trac 7 import patchbot 8 import db 9 10 from db import tickets, logs 11 from util import now_str, current_reports 12 13 app = Flask(__name__) 14 15 @app.route("/reports") 16 def reports(): 17 pass 18 19 @app.route("/trusted") 20 @app.route("/trusted/") 21 def trusted_authors(): 22 authors = collections.defaultdict(int) 23 for ticket in tickets.find({'status': 'closed : fixed'}): 24 for author in ticket["authors"]: 25 authors[author] += 1 26 if 'pretty' in request.args: 27 indent = 4 28 else: 29 indent = None 30 response = make_response(json.dumps(authors, default=lambda x: None, indent=indent)) 31 response.headers['Content-type'] = 'text/plain' 32 return response 33 34 @app.route("/") 35 @app.route("/ticket") 36 @app.route("/ticket/") 37 def ticket_list(): 38 authors = None 39 if 'query' in request.args: 40 query = json.loads(request.args.get('query')) 41 else: 42 status = request.args.get('status', 'needs_review') 43 if status == 'all': 44 query = {} 45 elif status in ('new', 'closed'): 46 query = {'status': {'$regex': status + '.*' }} 47 elif status in ('open'): 48 query = {'status': {'$regex': 'needs_.*|positive_review' }} 49 else: 50 query = {'status': status} 51 if 'todo' in request.args: 52 query['patches'] = {'$not': {'$size': 0}} 53 query['spkgs'] = {'$size': 0} 54 if 'authors' in request.args: 55 authors = request.args.get('authors').split(':') 56 query['authors'] = {'$in': authors} 57 if 'order' in request.args: 58 order = request.args.get('order') 59 else: 60 order = 'last_activity' 61 if 'base' in request.args: 62 base = request.args.get('base') 63 if base == 'all': 64 base = None 65 else: 66 base = global_base 67 if 'author' in request.args: 68 query['authors'] = request.args.get('author') 69 if 'participant' in request.args: 70 query['participants'] = request.args.get('participant') 71 all = patchbot.filter_on_authors(tickets.find(query).sort(order), authors) 72 if 'raw' in request.args: 73 if 'pretty' in request.args: 74 indent = 4 75 else: 76 indent = None 77 response = make_response(json.dumps(list(all), default=lambda x: None, indent=indent)) 78 response.headers['Content-type'] = 'text/plain' 79 return response 80 summary = dict((key, 0) for key in status_order) 81 def preprocess(all): 82 for ticket in all: 83 ticket['report_count'], ticket['report_status'], ticket['report_status_composite'] = get_ticket_status(ticket, base) 84 if 'reports' in ticket: 85 ticket['pending'] = len([r for r in ticket['reports'] if r['status'] == 'Pending']) 86 summary[ticket['report_status']] += 1 87 yield ticket 88 ticket0 = db.lookup_ticket(0) 89 versions = list(set(report['base'] for report in ticket0['reports'])) 90 versions.sort(trac.compare_version) 91 versions = [(v, get_ticket_status(ticket0, v)) for v in versions if v != '4.7.'] 92 return render_template("ticket_list.html", tickets=preprocess(all), summary=summary, base=base, base_status=get_ticket_status(db.lookup_ticket(0), base), versions=versions, status_order=status_order) 93 94 def format_patches(ticket, patches, deps=None, required=None): 95 if deps is None: 96 deps = [] 97 if required is not None: 98 required = set(required) 99 def format_item(item): 100 if required is None or item in required: 101 note = "" 102 else: 103 note = "<span style='color: red'>(mismatch)</span>" 104 item = str(item) 105 if '#' in item: 106 url = trac.get_patch_url(ticket, item, raw=False) 107 title = item 108 elif '.' in item: 109 url = '/?base=%s' % item 110 title = 'sage-%s' % item 111 else: 112 url = '/ticket/%s' % item 113 title = '#%s' % item 114 return "<a href='%s'>%s</a> %s" % (url, title, note) 115 116 missing_deps = missing_patches = '' 117 if required is not None: 118 required_patches_count = len([p for p in required if '#' in str(p)]) 119 if len(deps) < len(required) - required_patches_count: 120 missing_deps = "<li><span style='color: red'>(missing deps)</span>\n" 121 if len(patches) < required_patches_count: 122 missing_patches = "<li><span style='color: red'>(missing patches)</span>\n" 123 return ("<ol>" 124 + missing_deps 125 + "<li>\n" 126 + "\n<li>".join(format_item(patch) for patch in (deps + patches)) 127 + missing_patches 128 + "</ol>") 129 130 @app.route("/ticket/<int:ticket>/") 131 def render_ticket(ticket): 132 try: 133 info = trac.scrape(ticket, db=db, force='force' in request.args) 134 except: 135 info = tickets.find_one({'id': ticket}) 136 if info is None: 137 return "No such ticket." 138 if 'kick' in request.args: 139 info['retry'] = True 140 db.save_ticket(info) 141 if 'reports' in info: 142 info['reports'].sort(lambda a, b: -cmp(a['time'], b['time'])) 143 else: 144 info['reports'] = [] 145 146 old_reports = list(info['reports']) 147 patchbot.prune_pending(info) 148 if old_reports != info['reports']: 149 db.save_ticket(info) 150 151 def format_info(info): 152 new_info = {} 153 for key, value in info.items(): 154 if key == 'patches': 155 new_info['patches'] = format_patches(ticket, value) 156 elif key == 'reports' or key == 'pending': 157 pass 158 elif key == 'depends_on': 159 new_info[key] = ', '.join("<a href='/ticket/%s'>%s</a>" % (a, a) for a in value) 160 elif key == 'authors': 161 new_info[key] = ', '.join("<a href='/ticket/?author=%s'>%s</a>" % (a,a) for a in value) 162 elif key == 'participants': 163 new_info[key] = ', '.join("<a href='/ticket/?participant=%s'>%s</a>" % (a,a) for a in value) 164 elif isinstance(value, list): 165 new_info[key] = ', '.join(value) 166 elif key not in ('id', '_id'): 167 new_info[key] = value 168 return new_info 169 def preprocess_reports(all): 170 for item in all: 171 if 'patches' in item: 172 required = info['depends_on'] + info['patches'] 173 item['patch_list'] = format_patches(ticket, item['patches'], item.get('deps'), required) 174 if item['base'] != base: 175 item['base'] = "<span style='color: red'>%s</span>" % item['base'] 176 if 'time' in item: 177 item['log'] = log_name(info['id'], item) 178 yield item 179 return render_template("ticket.html", reports=preprocess_reports(info['reports']), ticket=ticket, info=format_info(info), status=get_ticket_status(info, base=base)[2]) 180 181 # The fact that this image is in the trac template lets the patchbot know 182 # when a page gets updated. 183 @app.route("/ticket/<int:ticket>/status.png") 184 def render_ticket_status(ticket): 185 try: 186 info = trac.scrape(ticket, db=db) 187 except: 188 info = tickets.find_one({'id': ticket}) 189 status = get_ticket_status(info, base=base)[2] 190 response = make_response(create_status_image(status)) 191 response.headers['Content-type'] = 'image/png' 192 response.headers['Cache-Control'] = 'no-cache' 193 return response 194 195 def get_or_set(ticket, key, default): 196 if key in ticket: 197 value = ticket[key] 198 else: 199 value = ticket[key] = default 200 return value 201 202 @app.route("/report/<int:ticket_id>", methods=['POST']) 203 def post_report(ticket_id): 204 try: 205 ticket = db.lookup_ticket(ticket_id) 206 if ticket is None: 207 ticket = trac.scrape(ticket_id) 208 if 'reports' not in ticket: 209 ticket['reports'] = [] 210 report = json.loads(request.form.get('report')) 211 assert isinstance(report, dict) 212 for fld in ['status', 'patches', 'spkgs', 'base', 'machine', 'time']: 213 assert fld in report 214 patchbot.prune_pending(ticket, report['machine']) 215 ticket['reports'].append(report) 216 if report['status'] != 'Pending': 217 db.logs.put(request.files.get('log'), _id=log_name(ticket_id, report)) 218 if 'retry' in ticket: 219 ticket['retry'] = False 220 ticket['last_activity'] = now_str() 221 db.save_ticket(ticket) 222 return "ok" 223 except: 224 traceback.print_exc() 225 return "error" 226 227 def log_name(ticket_id, report): 228 return "/log/%s/%s/%s" % (ticket_id, '/'.join(report['machine']), report['time']) 229 230 231 def shorten(lines): 232 timing = re.compile(r'\s*\[\d+\.\d* s\]\s*$') 233 skip = re.compile(r'(sage -t.*\(skipping\))|(byte-compiling)|(copying)|(\S+: \d+% \(\d+ of \d+\))$') 234 gcc = re.compile('(gcc)|(g\+\+)') 235 prev = None 236 for line in StringIO(lines): 237 if skip.match(line): 238 pass 239 elif prev is None: 240 prev = line 241 elif prev.startswith('sage -t') and timing.match(line): 242 prev = None 243 elif prev.startswith('python `which cython`') and '-->' in line: 244 prev = None 245 elif gcc.match(prev) and (gcc.match(line) or line.startswith('Time to execute')): 246 prev = line 247 else: 248 yield prev 249 prev = line 250 251 if prev is not None: 252 yield prev 253 254 def extract_plugin_log(data, plugin): 255 from patchbot import plugin_boundary 256 start = plugin_boundary(plugin) + "\n" 257 end = plugin_boundary(plugin, end=True) + "\n" 258 all = [] 259 include = False 260 for line in StringIO(data): 261 if line == start: 262 include = True 263 if include: 264 all.append(line) 265 if line == end: 266 break 267 return ''.join(all) 268 269 @app.route("/ticket/<id>/log/<path:log>") 270 def get_ticket_log(id, log): 271 return get_log(log) 272 273 @app.route("/log/<path:log>") 274 def get_log(log): 275 path = "/log/" + log 276 if not logs.exists(path): 277 data = "No such log!" 278 else: 279 data = bz2.decompress(logs.get(path).read()) 280 if 'plugin' in request.args: 281 data = extract_plugin_log(data, request.args.get('plugin')) 282 if 'short' in request.args: 283 response = Response(shorten(data), direct_passthrough=True) 284 else: 285 response = make_response(data) 286 response.headers['Content-type'] = 'text/plain' 287 return response 288 289 status_order = ['New', 'ApplyFailed', 'BuildFailed', 'TestsFailed', 'PluginFailed', 'TestsPassed', 'Pending', 'NoPatch', 'Spkg'] 290 # TODO: cleanup old records 291 # status_order += ['started', 'applied', 'built', 'tested'] 292 293 status_colors = { 294 'New' : 'white', 295 'ApplyFailed': 'red', 296 'BuildFailed': 'red', 297 'TestsFailed': 'yellow', 298 'TestsPassed': 'green', 299 'PluginFailed': 'blue', 300 'Pending' : 'white', 301 'NoPatch' : 'purple', 302 'Spkg' : 'purple', 303 } 304 305 @app.route("/blob/<status>") 306 def status_image(status): 307 response = make_response(create_status_image(status)) 308 response.headers['Content-type'] = 'image/png' 309 response.headers['Cache-Control'] = 'max-age=3600' 310 return response 311 312 def create_status_image(status): 313 if ',' in status: 314 status_list = status.split(',') 315 if len(set(status_list)) == 1: 316 status = status_list[0] 317 else: 318 try: 319 from PIL import Image 320 import numpy 321 path = 'images/_cache/' + ','.join(status_list) + '-blob.png' 322 if not os.path.exists(path): 323 composite = numpy.asarray(Image.open(status_image(status_list[0]))).copy() 324 height, width, _ = composite.shape 325 for ix, status in enumerate(reversed(status_list)): 326 slice = numpy.asarray(Image.open(status_image(status))) 327 start = ix * width / len(status_list) 328 end = (ix + 1) * width / len(status_list) 329 composite[:,start:end,:] = slice[:,start:end,:] 330 if not os.path.exists('images/_cache'): 331 os.mkdir('images/_cache') 332 Image.fromarray(composite, 'RGBA').save(path) 333 return open(path).read() 334 except ImportError: 335 print "here" 336 status = min_status(status_list) 337 return open(status_image(status)).read() 338 339 def status_image(status): 340 return 'images/%s-blob.png' % status_colors[status] 341 342 def min_status(status_list): 343 index = min(status_order.index(status) for status in status_list) 344 return status_order[index] 345 346 @app.route("/robots.txt") 347 def robots(): 348 return """ 349 User-agent: * 350 Disallow: /ticket/1303/status.png 351 Disallow: /blob/ 352 Crawl-delay: 5 353 """.lstrip() 354 355 @app.route("/favicon.ico") 356 def robots(): 357 response = make_response(open('images/%s-blob.png' % status_colors['TestsPassed']).read()) 358 response.headers['Content-type'] = 'image/png' 359 return response 360 361 def get_ticket_status(ticket, base=None): 362 all = current_reports(ticket, base=base) 363 if len(all): 364 status_list = [report['status'] for report in all] 365 if len(set(status_list)) == 1: 366 composite = single = status_list[0] 367 else: 368 composite = ','.join(status_list) 369 single = min_status(status_list) 370 return len(all), single, composite 371 elif ticket['spkgs']: 372 return 0, 'Spkg', 'Spkg' 373 elif not ticket['patches']: 374 return 0, 'NoPatch', 'NoPatch' 375 else: 376 return 0, 'New', 'New' 377 378 def main(args): 379 parser = OptionParser() 380 parser.add_option("-b", "--base", dest="base") 381 parser.add_option("-p", "--port", dest="port") 382 parser.add_option("--debug", dest="debug", default=True) 383 (options, args) = parser.parse_args(args) 384 385 global global_base, base 386 global_base = base = options.base 387 app.run(debug=options.debug, host="0.0.0.0", port=int(options.port)) 388 389 if __name__ == '__main__': 390 main(sys.argv) -
new file patchbot/templates/base.html
diff --git a/patchbot/templates/base.html b/patchbot/templates/base.html new file mode 100644
- + 1 <!DOCTYPE HTML> 2 <html> 3 <head> 4 {% block head %} 5 {% endblock head %} 6 </head> 7 <body> 8 9 {% block body %} 10 11 {% endblock body %} 12 13 <script type="text/javascript"> 14 15 var _gaq = _gaq || []; 16 _gaq.push(['_setAccount', 'UA-20037535-1']); 17 _gaq.push(['_trackPageview']); 18 19 (function() { 20 var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true; 21 ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'; 22 var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s); 23 })(); 24 25 </script> 26 </body> 27 </html> -
new file patchbot/templates/ticket.html
diff --git a/patchbot/templates/ticket.html b/patchbot/templates/ticket.html new file mode 100644
- + 1 {% extends 'base.html' %} 2 3 {% block head %} 4 <title>#{{ticket}} PatchBot Results</title> 5 <link rel="shortcut icon" href="/blob/{{status}}" /> 6 {% endblock %} 7 8 {% block body %} 9 10 <h2> 11 <img src="/blob/{{status}}"> 12 {{ticket}} 13 </h2> 14 <a href="http://trac.sagemath.org/sage_trac/ticket/{{ticket}}"> 15 {{info.title}} 16 </a> 17 <br><br> 18 19 <table> 20 {% for key, value in info.items(): %} 21 <tr> 22 <td align='right'>{{key}}:</td> 23 <td>{{value|safe}}</td> 24 </tr> 25 {% endfor %} 26 </table> 27 <br> 28 <hr> 29 <table width='100%'> 30 {% for report in reports: %} 31 <tr> 32 <td><img src="/blob/{{report.status}}"></td> 33 <td><b>{{report.status}}</b></td> 34 <td>{{report.base|safe}}</td> 35 <td>{{'/'.join(report.machine)}}</td> 36 <td>{{report.time}}</td> 37 <td align='center'><a href='{{report.log}}'>log</a> <a href='{{report.log}}?short'>shortlog</a></td> 38 </tr> 39 <tr> 40 <td colspan=2></td> 41 <td colspan=3 valign='top'> 42 {{report.patch_list|safe}} 43 </td> 44 <td valign='top'> 45 <ul style='list-style-type: none'> 46 {% for plugin, status in report.get('plugins', []) %} 47 <li> 48 <img height="16" src="/blob/{{['PluginFailed', 'TestsPassed'][status]}}"> 49 <a href='{{report.log}}?plugin={{plugin}}'>{{plugin}}</a> 50 {% endfor %} 51 </ul> 52 </td> 53 </tr> 54 {% endfor %} 55 </table> 56 57 {% endblock %} -
new file patchbot/templates/ticket_list.html
diff --git a/patchbot/templates/ticket_list.html b/patchbot/templates/ticket_list.html new file mode 100644
- + 1 {% extends 'base.html' %} 2 3 {% block head %} 4 <title>Sage PatchBot</title> 5 <link rel="shortcut icon" href="/blob/{{base_status[1]}}" /> 6 {% endblock %} 7 8 {% block body %} 9 10 <h1>Sage Patchbot</h1> 11 <img src='/blob/{{base_status[1]}}' height=16> 12 Results against Sage {{base}} 13 (<a href='/ticket/0'>{{base_status[0]}} reports</a>).<br><br> 14 See results against 15 {% for version, status in versions: %} 16 <img src='/blob/{{status[1]}}' height=16> 17 <a href='?base={{version}}'>{{version}}</a> 18 {% endfor %} 19 20 <hr> 21 22 <table> 23 {% for ticket in tickets: %} 24 <tr> 25 <td>{{'*' if ticket.pending else ''}}</td> 26 <td>{{ticket.report_count}}</td> 27 <td><img src='/blob/{{ticket.report_status_composite}}' alt='{{ticket.report_status_composite}}' title='{{ticket.report_status_composite}}' height=16></td> 28 <td align='right'> 29 <a href="/ticket/{{ticket.id}}/">{{ticket.id}}</a> 30 </td> 31 <td>{{ticket.title}}</td> 32 <td>{{ticket.status}}</td> 33 </tr> 34 {% endfor %} 35 </table> 36 37 <hr> 38 39 <table> 40 {% for status in status_order %} 41 <tr> 42 <td><img src='/blob/{{status}}' height=16></td> 43 <td>{{status}}</td> 44 <td>{{summary[status]}}</td> 45 </tr> 46 {% endfor %} 47 </table> 48 49 {% endblock %} -
new file patchbot/trac.py
diff --git a/patchbot/trac.py b/patchbot/trac.py new file mode 100644
- + 1 TRAC_URL = "http://trac.sagemath.org/sage_trac" 2 3 import re, hashlib, urllib2, os, sys, traceback, time, subprocess 4 5 from util import do_or_die, extract_version, compare_version, get_base, now_str 6 7 def digest(s): 8 """ 9 Computes a cryptographic hash of the string s. 10 """ 11 return hashlib.md5(s).hexdigest() 12 13 def get_url(url): 14 """ 15 Returns the contents of url as a string. 16 """ 17 try: 18 url = url.replace(' ', '%20') 19 handle = urllib2.urlopen(url, timeout=5) 20 data = handle.read() 21 handle.close() 22 return data 23 except: 24 print url 25 raise 26 27 def get_patch_url(ticket, patch, raw=True): 28 if raw: 29 return "%s/raw-attachment/ticket/%s/%s" % (TRAC_URL, ticket, patch) 30 else: 31 return "%s/attachment/ticket/%s/%s" % (TRAC_URL, ticket, patch) 32 33 def get_patch(ticket, patch): 34 return get_url(get_patch_url(ticket, patch)) 35 36 def scrape(ticket_id, force=False, db=None): 37 """ 38 Scrapes the trac page for ticket_id, updating the database if needed. 39 """ 40 ticket_id = int(ticket_id) 41 if ticket_id == 0: 42 if db is not None: 43 db_info = db.lookup_ticket(ticket_id) 44 if db_info is not None: 45 return db_info 46 return { 47 'id' : ticket_id, 48 'title' : 'base', 49 'page_hash' : '0', 50 'status' : 'base', 51 'priority' : 'base', 52 'component' : 'base', 53 'depends_on' : [], 54 'spkgs' : [], 55 'patches' : [], 56 'authors' : [], 57 'participants' : [], 58 } 59 60 rss = get_url("%s/ticket/%s?format=rss" % (TRAC_URL, ticket_id)) 61 page_hash = digest(rss) # rss isn't as brittle 62 if db is not None: 63 # TODO: perhaps the db caching should be extracted outside of this function... 64 db_info = db.lookup_ticket(ticket_id) 65 if not force and db_info is not None and db_info['page_hash'] == page_hash: 66 return db_info 67 # TODO: Is there a better format that still has all the information? 68 html = get_url("%s/ticket/%s" % (TRAC_URL, ticket_id)) 69 authors = set() 70 patches = [] 71 for patch, who in extract_patches(rss): 72 authors.add(who) 73 patches.append(patch + "#" + digest(get_patch(ticket_id, patch))) 74 authors = list(authors) 75 data = { 76 'id' : ticket_id, 77 'title' : extract_title(rss), 78 'page_hash' : page_hash, 79 'status' : extract_status(html), 80 'milestone' : extract_milestone(html), 81 'merged' : extract_merged(html), 82 'priority' : extract_priority(html), 83 'component' : extract_component(html), 84 'depends_on' : extract_depends_on(html), 85 'spkgs' : extract_spkgs(html), 86 'patches' : patches, 87 'authors' : authors, 88 'participants' : extract_participants(rss), 89 'last_activity' : now_str(), 90 } 91 if db is not None: 92 db.save_ticket(data) 93 db_info = db.lookup_ticket(ticket_id) 94 return db_info 95 else: 96 return data 97 98 def extract_tag(sgml, tag): 99 """ 100 Find the first occurance of the tag start (including attributes) and 101 return the contents of that tag (really, up until the next end tag 102 of that type). 103 104 Crude but fast. 105 """ 106 tag_name = tag[1:-1] 107 if ' ' in tag_name: 108 tag_name = tag_name[:tag_name.index(' ')] 109 end = "</%s>" % tag_name 110 start_ix = sgml.find(tag) 111 if start_ix == -1: 112 return None 113 end_ix = sgml.find(end, start_ix) 114 if end_ix == -1: 115 return None 116 return sgml[start_ix + len(tag) : end_ix].strip() 117 118 def extract_status(html): 119 """ 120 Extracts the status of a ticket from the html page. 121 """ 122 status = extract_tag(html, '<span class="status">') 123 if status is None: 124 return 'unknown' 125 status = status.strip('()') 126 status = status.replace('defect', '').replace('enhancement', '').strip() 127 return status 128 129 def extract_priority(html): 130 """ 131 Extracts any spkgs for a ticket from the html page. 132 """ 133 return extract_tag(html, '<td headers="h_priority">') 134 135 def extract_milestone(html): 136 milestone_field = extract_tag(html, '<td headers="h_milestone">') 137 return extract_version(milestone_field) 138 139 def extract_merged(html): 140 merged_field = extract_tag(html, '<td headers="h_merged">') 141 return extract_version(merged_field) 142 143 def extract_component(html): 144 return extract_tag(html, '<td headers="h_component">') 145 146 def extract_title(rss): 147 title = extract_tag(rss, '<title>') 148 return re.sub(r'.*#\d+:', '', title).strip() 149 150 folded_regex = re.compile('all.*(folded|combined|merged)') 151 subsequent_regex = re.compile('second|third|fourth|next|on top|after') 152 attachment_regex = re.compile(r"<strong>attachment</strong>\s*set to <em>(.*)</em>", re.M) 153 rebased_regex = re.compile(r"([-.]?rebased?)|(-v\d)") 154 def extract_patches(rss): 155 """ 156 Extracts the list of patches for a ticket from the rss feed. 157 158 Tries to deduce the subset of attached patches to apply based on 159 160 (1) "Apply ..." in comment text 161 (2) Mercurial .N naming 162 (3) "rebased" in name 163 (3) Chronology 164 """ 165 all_patches = [] 166 patches = [] 167 authors = {} 168 for item in rss.split('<item>'): 169 who = extract_tag(item, '<dc:creator>') 170 description = extract_tag(item, '<description>').replace('<', '<').replace('>', '>') 171 m = attachment_regex.search(description) 172 comments = description[description.find('</ul>') + 1:] 173 # Look for apply... followed by patch names 174 for line in comments.lower().split('\n'): 175 if 'apply' in line: 176 new_patches = [] 177 for p in line[line.index('apply'):].split(','): 178 for pp in p.strip().split(): 179 if pp in all_patches: 180 new_patches.append(pp) 181 if new_patches or (m and not subsequent_regex.search(line)): 182 patches = new_patches 183 elif m and folded_regex.search(line): 184 patches = [] # will add this patch below 185 if m is not None: 186 attachment = m.group(1) 187 base, ext = os.path.splitext(attachment) 188 if '.' in base: 189 try: 190 base2, ext2 = os.path.splitext(base) 191 count = int(ext2[1:]) 192 for i in range(count): 193 if i: 194 older = "%s.%s%s" % (base2, i, ext) 195 else: 196 older = "%s%s" % (base2, ext) 197 if older in patches: 198 patches.remove(older) 199 except: 200 pass 201 if rebased_regex.search(attachment): 202 older = rebased_regex.sub('', attachment) 203 if older in patches: 204 patches.remove(older) 205 if ext in ('.patch', '.diff'): 206 all_patches.append(attachment) 207 patches.append(attachment) 208 authors[attachment] = who 209 return [(p, authors[p]) for p in patches] 210 211 participant_regex = re.compile("<strong>attachment</strong>\w*set to <em>(.*)</em>") 212 def extract_participants(rss): 213 """ 214 Extracts any spkgs for a ticket from the html page. 215 """ 216 all = set() 217 for item in rss.split('<item>'): 218 who = extract_tag(item, '<dc:creator>') 219 if who: 220 all.add(who) 221 return list(all) 222 223 spkg_url_regex = re.compile(r"(?:(?:http://)|(?:/attachment/)).*?\.spkg") 224 #spkg_url_regex = re.compile(r"http://.*?\.spkg") 225 def extract_spkgs(html): 226 """ 227 Extracts any spkgs for a ticket from the html page. 228 229 Just searches for urls ending in .spkg. 230 """ 231 return list(set(spkg_url_regex.findall(html))) 232 233 def min_non_neg(*rest): 234 non_neg = [a for a in rest if a >= 0] 235 if len(non_neg) == 0: 236 return rest[0] 237 elif len(non_neg) == 1: 238 return non_neg[0] 239 else: 240 return min(*non_neg) 241 242 ticket_url_regex = re.compile(r"%s/ticket/(\d+)" % TRAC_URL) 243 def extract_depends_on(html): 244 deps_field = extract_tag(html, '<td headers="h_dependencies">') 245 deps = [] 246 for dep in re.finditer(r'ticket/(\d+)', deps_field): 247 deps.append(int(dep.group(1))) 248 version = re.search(r'sage-\d+(\.\d)+(\.\w+)?', deps_field) 249 if version: 250 deps.insert(0, version.group(0)) 251 return deps 252 253 254 255 safe = re.compile('[-+A-Za-z0-9._]*') 256 def ensure_safe(items): 257 """ 258 Raise an error if item has any spaces in it. 259 """ 260 if isinstance(items, (str, unicode)): 261 m = safe.match(items) 262 if m is None or m.end() != len(items): 263 raise ValueError, "Unsafe patch name '%s'" % items 264 else: 265 for item in items: 266 ensure_safe(item) 267 268 269 def pull_from_trac(sage_root, ticket, branch=None, force=None, interactive=None): 270 # Should we set/unset SAGE_ROOT and SAGE_BRANCH here? Fork first? 271 if branch is None: 272 branch = str(ticket) 273 if not os.path.exists('%s/devel/sage-%s' % (sage_root, branch)): 274 do_or_die('%s/sage -b main' % (sage_root,)) 275 do_or_die('%s/sage -clone %s' % (sage_root, branch)) 276 os.chdir('%s/devel/sage-%s' % (sage_root, branch)) 277 if interactive: 278 raise NotImplementedError 279 if not os.path.exists('.hg/patches'): 280 do_or_die('hg qinit') 281 series = [] 282 elif not os.path.exists('.hg/patches/series'): 283 series = [] 284 else: 285 series = open('.hg/patches/series').read().split('\n') 286 287 base = get_base(sage_root) 288 desired_series = [] 289 seen_deps = [] 290 def append_patch_list(ticket, dependency=False): 291 if ticket in seen_deps: 292 return 293 print "Looking at #%s" % ticket 294 seen_deps.append(ticket) 295 data = scrape(ticket) 296 if dependency and 'closed' in data['status']: 297 merged = data.get('merged') 298 if merged is None: 299 merged = data.get('milestone') 300 if merged is None or compare_version(merged, base) <= 0: 301 print "#%s already applied (%s <= %s)" % (ticket, merged, base) 302 return 303 if data['spkgs']: 304 raise NotImplementedError, "Spkgs not yet handled." 305 if data['depends_on']: 306 for dep in data['depends_on']: 307 if isinstance(dep, basestring) and '.' in dep: 308 if compare_version(base, dep) < 0: 309 raise ValueError, "%s < %s for %s" % (base, dep, ticket) 310 continue 311 append_patch_list(dep, dependency=True) 312 print "Patches for #%s:" % ticket 313 print " " + "\n ".join(data['patches']) 314 for patch in data['patches']: 315 patchfile, hash = patch.split('#') 316 desired_series.append((hash, patchfile, get_patch_url(ticket, patchfile))) 317 append_patch_list(ticket) 318 319 ensure_safe(series) 320 ensure_safe(patch for hash, patch, url in desired_series) 321 322 last_good_patch = '-a' 323 to_push = list(desired_series) 324 for series_patch, (hash, patch, url) in zip(series, desired_series): 325 if not series_patch: 326 break 327 next_hash = digest(open('.hg/patches/%s' % series_patch).read()) 328 # print next_hash, hash, series_patch 329 if next_hash == hash: 330 to_push.pop(0) 331 last_good_patch = series_patch 332 else: 333 break 334 335 try: 336 if last_good_patch != '-a': 337 # In case it's not yet pushed... 338 if last_good_patch not in os.popen2('hg qapplied')[1].read().split('\n'): 339 do_or_die('hg qpush %s' % last_good_patch) 340 do_or_die('hg qpop %s' % last_good_patch) 341 for hash, patch, url in to_push: 342 if patch in series: 343 if not force: 344 raise Exception, "Duplicate patch: %s" % patch 345 old_patch = patch 346 while old_patch in series: 347 old_patch += '-old' 348 do_or_die('hg qrename %s %s' % (patch, old_patch)) 349 do_or_die('hg qimport %s && hg qpush' % url) 350 do_or_die('hg qapplied') 351 except: 352 os.system('hg qpop -a') 353 raise 354 355 356 def push_from_trac(sage_root, ticket, branch=None, force=None, interactive=None): 357 raise NotImplementedError 358 359 360 361 if __name__ == '__main__': 362 force = False 363 apply = False 364 for ticket in sys.argv[1:]: 365 if ticket == '-f': 366 force = True 367 continue 368 if ticket == '-a': 369 apply = True 370 continue 371 if '-' in ticket: 372 start, end = ticket.split('-') 373 tickets = range(int(start), int(end) + 1) 374 else: 375 tickets = [int(ticket)] 376 for ticket in tickets: 377 try: 378 print ticket, scrape(ticket, force=force) 379 if apply: 380 pull_from_trac(os.environ['SAGE_ROOT'], ticket, force=True) 381 time.sleep(1) 382 except Exception: 383 print "Error for", ticket 384 traceback.print_exc() 385 force = apply = False 386 # pull_from_trac('/Users/robertwb/sage/current', ticket, force=True) -
new file patchbot/util.py
diff --git a/patchbot/util.py b/patchbot/util.py new file mode 100644
- + 1 import os, re, subprocess, time 2 3 4 DATE_FORMAT = '%Y-%m-%d %H:%M:%S %z' 5 def now_str(): 6 return time.strftime(DATE_FORMAT) 7 8 def parse_datetime(s): 9 # The one thing Python can't do is parse dates... 10 return time.mktime(time.strptime(s[:-5].strip(), DATE_FORMAT[:-3])) + 60*int(s[-5:].strip()) 11 12 def prune_pending(ticket, machine=None, timeout=6*60*60): 13 if 'reports' in ticket: 14 reports = ticket['reports'] 15 else: 16 return [] 17 # TODO: is there a better way to handle time zones? 18 now = time.time() + 60 * int(time.strftime('%z')) 19 for report in list(reports): 20 if report['status'] == 'Pending': 21 t = parse_datetime(report['time']) 22 if report['machine'] == machine: 23 reports.remove(report) 24 elif now - t > timeout: 25 reports.remove(report) 26 return reports 27 28 def current_reports(ticket, base=None, unique=False): 29 if 'reports' not in ticket: 30 return [] 31 if unique: 32 seen = set() 33 def first(x): 34 if x in seen: 35 return False 36 else: 37 seen.add(x) 38 return True 39 else: 40 first = lambda x: True 41 reports = list(ticket['reports']) 42 reports.sort(lambda a, b: cmp(b['time'], a['time'])) 43 return filter(lambda report: (ticket['patches'] == report['patches'] and 44 ticket['spkgs'] == report['spkgs'] and 45 ticket['depends_on'] == (report.get('deps') or []) and 46 (not base or base == report['base'])) and 47 first('/'.join(report['machine'])), 48 reports) 49 50 def do_or_die(cmd): 51 print cmd 52 res = os.system(cmd) 53 if res: 54 raise Exception, "%s %s" % (res, cmd) 55 56 def extract_version(s): 57 m = re.search(r'\d+(\.\d+)+(\.\w+)', s) 58 if m: 59 return m.group(0) 60 61 def compare_version(a, b): 62 a = a.replace("beta", "beta.") 63 b = b.replace("beta", "beta.") 64 a += '.z' 65 b += '.z' 66 def maybe_int(s): 67 try: 68 return 1, int(s) 69 except ValueError: 70 return 0, s 71 return cmp([maybe_int(v) for v in a.split('.')], 72 [maybe_int(v) for v in b.split('.')]) 73 74 def get_base(sage_root): 75 p = subprocess.Popen([os.path.join(sage_root, 'sage'), '-v'], stdout=subprocess.PIPE) 76 if p.wait(): 77 raise ValueError, "Invalid sage_root='%s'" % sage_root 78 version_info = p.stdout.read() 79 return re.search(r'Sage Version ([\d.]+\w*)', version_info).groups()[0]