From adc0df53a5baf977ec1a881e79385ecbfd8ccedb Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Sun, 22 Feb 2026 17:01:12 +0000 Subject: [PATCH] Adds PR checks --- .github/workflows/pr-check.yml | 125 +++++++ .github/workflows/pr-comment.yml | 105 ++++++ .../__pycache__/check-links.cpython-310.pyc | Bin 0 -> 5842 bytes .../check-readme-edits.cpython-310.pyc | Bin 0 -> 4080 bytes .../check-template.cpython-310.pyc | Bin 0 -> 5359 bytes .../check-yaml-diff.cpython-310.pyc | Bin 0 -> 9772 bytes .../detect-changes.cpython-310.pyc | Bin 0 -> 1836 bytes .../__pycache__/warn-non-yaml.cpython-310.pyc | Bin 0 -> 2220 bytes lib/checks/check-links.py | 211 +++++++++++ lib/checks/check-readme-edits.py | 123 +++++++ lib/checks/check-template.py | 206 +++++++++++ lib/checks/check-yaml-diff.py | 342 ++++++++++++++++++ lib/checks/detect-changes.py | 50 +++ lib/checks/warn-non-yaml.py | 58 +++ lib/requirements.txt | 1 + lib/schema.json | 69 ++-- 16 files changed, 1269 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/pr-check.yml create mode 100644 .github/workflows/pr-comment.yml create mode 100644 lib/checks/__pycache__/check-links.cpython-310.pyc create mode 100644 lib/checks/__pycache__/check-readme-edits.cpython-310.pyc create mode 100644 lib/checks/__pycache__/check-template.cpython-310.pyc create mode 100644 lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc create mode 100644 lib/checks/__pycache__/detect-changes.cpython-310.pyc create mode 100644 lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc create mode 100644 lib/checks/check-links.py create mode 100644 lib/checks/check-readme-edits.py create mode 100644 lib/checks/check-template.py create mode 100644 lib/checks/check-yaml-diff.py create mode 100644 lib/checks/detect-changes.py create mode 100644 lib/checks/warn-non-yaml.py diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..109df8e --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,125 @@ +name: PR Check + +on: + pull_request: + branches: [main] + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + check-pr: + name: Check PR + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch base ref + run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: pip install -q -r lib/requirements.txt + + - name: Detect changed files + id: changes + run: python lib/checks/detect-changes.py --base-ref ${{ github.event.pull_request.base.sha }} + + - name: Check for direct README edits + id: readme + run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }} + + - name: Schema validation + id: schema + if: steps.changes.outputs.yaml_changed == 'true' + run: make validate + + - name: YAML diff analysis + id: diff + if: steps.changes.outputs.yaml_changed == 'true' + run: python lib/checks/check-yaml-diff.py --base-ref ${{ github.event.pull_request.base.sha }} + + - name: Link validation + if: steps.changes.outputs.yaml_changed == 'true' && steps.diff.outputs.has_service_changes == 'true' + run: python lib/checks/check-links.py --diff-json /tmp/pr-diff.json + + - name: PR template check + id: template + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: python lib/checks/check-template.py + + - name: Non-YAML changes warning + if: steps.changes.outputs.non_yaml_changed == 'true' + run: python lib/checks/warn-non-yaml.py --base-ref ${{ github.event.pull_request.base.sha }} + + - name: Save PR metadata + if: always() + run: | + mkdir -p /tmp/pr-meta + echo "${{ github.event.pull_request.number }}" > /tmp/pr-meta/number.txt + echo "${{ github.run_id }}" > /tmp/pr-meta/run-id.txt + + - name: Build failure comment + if: failure() + run: | + cat > /tmp/pr-meta/comment.md << 'HEADER' + + > [!CAUTION] + > ## PR check failed + HEADER + + if [ "${{ steps.readme.outcome }}" = "failure" ]; then + cat >> /tmp/pr-meta/comment.md << 'EOF' + + ### Direct README edits + The auto-generated section of the README must not be edited directly. + Please make your changes in `awesome-privacy.yml` instead — the README is regenerated from it. + EOF + fi + + if [ "${{ steps.schema.outcome }}" = "failure" ]; then + cat >> /tmp/pr-meta/comment.md << 'EOF' + + ### Schema validation failed + `awesome-privacy.yml` does not conform to the expected schema. + Please check your YAML against the structure defined in `lib/schema.json`. + EOF + fi + + if [ "${{ steps.diff.outcome }}" = "failure" ]; then + cat >> /tmp/pr-meta/comment.md << 'EOF' + + ### Single-entry rule + Each PR should add, modify, or remove only **one** service at a time. + If you need to change multiple services, please split them into separate PRs. + EOF + fi + + if [ "${{ steps.template.outcome }}" = "failure" ]; then + cat >> /tmp/pr-meta/comment.md << 'EOF' + + ### PR template incomplete + Your PR description is missing required sections. + Please fill out the **Type**, **Changes**, and **Checklist** sections of the PR template. + EOF + fi + + # Footer with link to logs + printf '\n---\n*See the [workflow logs](%s/%s/actions/runs/%s) for full details.*\n' \ + "${{ github.server_url }}" "${{ github.repository }}" "${{ github.run_id }}" \ + >> /tmp/pr-meta/comment.md + + - name: Upload PR metadata + if: always() + uses: actions/upload-artifact@v4 + with: + name: pr-meta + path: /tmp/pr-meta/ diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml new file mode 100644 index 0000000..4034dba --- /dev/null +++ b/.github/workflows/pr-comment.yml @@ -0,0 +1,105 @@ +name: PR Comment + +on: + workflow_run: + workflows: ["PR Check"] + types: [completed] + +permissions: + actions: read + pull-requests: write + +jobs: + comment: + name: Post PR comment + runs-on: ubuntu-latest + if: github.event.workflow_run.event == 'pull_request' + + steps: + - name: Download PR metadata + id: download + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: pr-meta + path: pr-meta + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Post or update comment + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const marker = ''; + const conclusion = context.payload.workflow_run.conclusion; + + // Determine the PR number + let prNumber; + const numberFile = 'pr-meta/number.txt'; + if (fs.existsSync(numberFile)) { + prNumber = parseInt(fs.readFileSync(numberFile, 'utf8').trim()); + } else { + // workflow_run.pull_requests is empty for fork PRs, so + // fall back to searching by head SHA if needed + const prs = context.payload.workflow_run.pull_requests; + if (prs && prs.length > 0) { + prNumber = prs[0].number; + } else { + const headSha = context.payload.workflow_run.head_sha; + const { data: prList } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + sort: 'updated', + direction: 'desc', + per_page: 10, + }); + const match = prList.find(pr => pr.head.sha === headSha); + if (!match) { + console.log(`No open PR found for SHA ${headSha} — skipping comment.`); + return; + } + prNumber = match.number; + } + } + + // Find existing bot comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + const existing = comments.find(c => c.body.includes(marker)); + + if (conclusion === 'failure') { + const commentFile = 'pr-meta/comment.md'; + if (!fs.existsSync(commentFile)) { + console.log('No comment.md found — skipping comment.'); + return; + } + const body = fs.readFileSync(commentFile, 'utf8').trim(); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } + } else if (existing) { + // Checks passed — remove stale failure comment + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + } diff --git a/lib/checks/__pycache__/check-links.cpython-310.pyc b/lib/checks/__pycache__/check-links.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4fc6b746504ea50ff0d556d17c5e0d41c9b48b6b GIT binary patch literal 5842 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

prrg!rlf{2vwG+N>Y3Apenu0UXc=tME<^tNTr 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 or3A-Q!?7K(6#hTfSrdsCC1b07kVHi2wiq literal 0 HcmV?d00001 diff --git a/lib/checks/__pycache__/check-template.cpython-310.pyc b/lib/checks/__pycache__/check-template.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d49b2561d824ecb8c9fd9a6115aa3b9f6c3ec9e1 GIT binary patch literal 5359 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 literal 0 HcmV?d00001 diff --git a/lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc b/lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..51c91bfd2bd882b16391f3f81379554355e72e06 GIT binary patch literal 9772 zcmb_iS!^6fdhUCA4i1Nhcu17gx;eHuvh3`7SMfTcu9X~#R7A>J({{$qsU|tpT&ix0 zW~0aPMh3D;V8l6)MPM@_fh2%m^Oj(r@*W^Bc_|<-$&;TK2oS+u<@>99hQmXGuvree z>hJ2RzmEU@i_N~ijDo+5r7x?$xS%NiO)tHFX}ny<lg((XPQ<+v%7gSkm3!1F; z1zpz0f+1^j!IZVNV0rp#YRleNroGIz8vhou@@vk^GJRWH=wrr0o~aA{%v>liYhl1M zR|mI-ww2}$^c&tf#8TU8PFWc7M))0Ov-D?5LuVPP7e>+cfMt`moNOCIo6Gu=w!CZ` zN83ZzpR^TZ+hI1q27%)U8)Cz#C)go2g8C>MWn-w1v2k`7^>KEDO`vwzQFaXV33i-0 zs86yJ>?G<_>=ZkV`ZPPk&Z0i!on_}9YW(lLGfxy%VcML0ij(1gWu_Z$Z6ox2Cs^?u zR$W?h7QJBI^BT^g>wAvdV9tu?GUu_!{c5w}JIy7>UHANE-J5Fh>SMREQQD~2N}218 zr6#W=i2Q0}x#mrIjeu`Be68kPa+;0W1`5ydJ^r{_0cyso0Z?9W>t2K52`+H2-hAxV zoR-I(yZ1_&*|ngx7Wj_qyfZgD6E81lIv0X^>q3i9(Q-?xezPH$xz4Krrgt4b@LG<) zRf(jUidT8)$JNw^Td&1C zX>ADmt(se3WbWHX2t6$$r|>MQeQTg~$Tb)MB4rLU&BXKAXxN#NlW<8CU6jUtY@Ho( z6TLgTc)iZ8F0SWcouD3c4$oa7@1rjHm&4S55T*lH_r}Yt?kLUr8(E#i^m3~M>0j#T zj{U|KCo%0?932}`pV-9zM-<^`psY%@DOpzE)RuK-&@a%K`MKFv1AWVg)Kzm^eE^m+ z6!fT?Z>#9mgas-?Ua_tjUk8~0SDjW6LCdPDg|+Ly|v z);lIhZ}kQFZBwV|wNEsbfkKkdy1K3uv*Cr?O{g5wNbgPGx$P|RW?jBXmBhUz zXN?q$vs?`vf2Fxz5;{O(5F8F)IzZCri-yQo+*Yv0y>e4(6=4M4$AK`VFbci0&cf>t zT;2fUO9yEaAb{B5y2JYo5qd+`wCI@~Y>D zM{c-i!k(H=%jHn4ax1jEOU_KOUu68X#TIWufBHh_YYmaRdvErg>sRN?_hx73g+W?E zq)!U<3M4nbtmK|w{ zCy3RE8#T>P^Qxt4s`Xvo%&UW1PA#Zo_}QOozv8c=O9fB&k5pZkSAT(*@K~S(;MFJE zpXJ+1pl#`qLR_gq)f(W;CpvM^oN`g|N7unk^m`A_18Lg8xyHs16TUSAi|K)yaAIoJ zW(IL@klIQIZ~nM91yz`-AagV6>Krg3KUf^m|AwYK^R91=3A1W69I!t>AJ;K~~3 zuyoFPlRqS;0rz+%q4Jrqa03jo!(^8nK0?Q^k8z|mjv1$z6GpS;HAMQ_^!#-B?)3ak zF7+`fD=|O_XPUtc;#o;@?x3BYq2eSJr>Hnf#W^ZYqbO=ZUE;6MYu9K=6OvGxW-k+^ z-#{0C421^D+Un%9oHhmu_t5wvKJGFKLk|7Sc@cxp%qjTw)n}Y8CCmE`;}pF>amL;WYfvQOM)x`*hi4b7Z7B84?%F~ zOIjNu!>rE~kVoBCt|?E{b}CAN2o{LYAwepfdsz}Bhc3zC)V;u^b95%Xk1Mq`Mgr75 z>X;h*b!>wZbMoIpv1_|J8pvCk!}Cj0QdKxkA7(#*++v$5Qy%IZb)@~J_6YJ^RQdPN zNuK@Jy3k6m3H6~+AMYhb<}Ko*O0(X2`(46Js_d8&p6k#^uDvX2RC=q{thhD*?b6Pe zuQmRCi~w&0UfnO+{7stl2UPr!ic3`dHWhDC@jEDlK_{_y9bKmAnQ#WZ{b3Y4Okt~o z#1h{cCjVW0*mXkW{m~;C;xd4J3~^{TL%?@1L2Ru{Y{)21PzSL)(l&E2sXM|ZnVcl6 z)9@TB%T4*ky;R<{{9Xd{X)GTkBrb?g>Pi=qnoat}H_-MGwf`7JI2n_yvy$i2jNe4} zqIn>x-XU~yhVC?C_#njZqoYs0X-pfUilOq`Xs8eib^Znbkf2JMybZTM0M|mSG}J0; zST>x$woPpYU_F7M8{1Yun$Q@-H)z*kh@u7mF2&Sw1*IlS9i<+n#ua9KWso&|G=Y&e zMjDZsj2ys7`=F7`{7RQ&euy#Yc+BN^wi_6u5#Dwt#^26HnJ5bx`y2GlVWpXPrGT2M1@L2`@yTOr@&F#1<+fnrnN$+R z0g;xzmb4~mX^bTpyJ}VTv9M($L2*xsEAA;lQ|>!fBdKKb!tQK5@%dnU5=4Ob?_mcb z13xoatT5%GMXCuKgm7J?8s0hqd)dB^0`!iR@;#vO@1n3ZTg}01Aig)?BgVhXw?=B` zJ@cJm72sFU&w$sMgLGs7LvwxF8KbH8x7w!}>;g!4$qYrDU6~pA|Hw@Lewks2nG&v_ z*d-+g^)yr5EoLhee4=##t!f=gxHD+MAO<0=OmEl>0(eAnqI#ykAiX(@M1ebMNVoMn(z za%0hwwu|4R1~TV-o{IZakTEUxaWcjH0Tn-?;yo%Bs35f~Qpwh0Ifx~NrWCQHNb^c; z&s{1Oso0~^JG>p+rN2TCpNvTlZ|gCiYHH@YLaG<;O7r{k=W3>6dyZnF7)GzoMh`f_kW<4XOn^hQ?d? zy6ddTN9<}3;u*@CY-tS1V)B0Yt2ix46nQX4&pMw|zfw{%s_esQ}ste&CC={ zp*4Mx@*0n;yxEYP&!@0nVai-6WQV+AVM@#*8|OWs#A_aZ4IlQh^pG4?Mk-}xZ86D` z{0I6;Cnbk~J_kj7@|p3BhiKj9d6B{;S4KK1XJIaTj!cbIKO+NVeVGmrhXiNCgkyk^ zUcwe&9HNBDVK4#Ps4diK)ClPOHzNa&iO0f|!h@6mli_>gpCz27gO`w0fRPTDXA~hI z_3MjlmLDfF^#dw&p#cdEZX%x#m3cKXdd3XBaLjNr=EKOum=kTA9RefRY-Aj1iZ&8o zn054p7{;PB)-%4DmUFL17Vx|dXDP!Dk1Jay<@mR?Eo)n8XC-d9OKgM!UWq@=|0T+@ zqY}rlP5n*ul5-x%_|8r<$xfXpD`$##`DK*COotDiN#Z!s#c?tjiG(1|=ilYmQv?Ty z>DXzAzz_^kZp&t8uzReGof*ac<1|cH01!-FRK8IC+%6HI5bJDsvBUm%T-=8=*BhP8 zeXk_U&0oJ;p1XhN&h)+aN@6IHhu+qiaOBjfI9qs)E-~b_hF|qFaSZnXc$0rf#qU!g zg8~ZP!_rK%qq!XB1+ZN3{*d4M=zt{RN2N@7F>>Z%!koytnz(@g=VF5=q4;hia<0Le zRBOHyVaSyHODckQzyqN$6FJl5o&uv(o}o@q*PRRtc%AXqJ8mi_$wkgRx;&9%!r`8! zBpTO=o@(%R$*6^Wk#ncx=|)bNL+`7xJ9^0pH7DXySF7cxs`FHHCZDQB!oRc2Foh9Q zcnDL^IfU7yoxa6EIH%(#dsR&HHG&^gK{i@iYhkQ5s|^7cu2pj@o-l)I0PRFdjIX08 z_KB=i=1BrFmt5;eB__;Twc+`EH*rOO;(wGAizEsiW7BEjKO+3(iV7?4BrFgg^g2*4 z4#s+su9eEk{Qrru{uLCY7Gc{p@@FXzW~;-1G8%kU?R@g&)z<+HBknekmYJ0BG4#o4 zBkJjA{7-?Vf~Wg81c>f@q%tk&u`;F0%+J*}V!u^n$0+V**5^7Dp0cGwMW}(y+snAn zdIHr70o5Y?iS~isHrgg4K`26cZ>It%CkfdU;YAjFLd1ViRrXgpVz^9z%S#A$0bXcs zwJ%CT8DvZXnho!cyMRX&ISe67Yva1 zx;2PcmGZAcQCjvKjttxo3`gb@byPu({-cNx5x?ah8vK8Pd~k^MqsPeVC@aX!Ewv0p zIViQkO}+PvRN!NXBJ*r0>SM!Fy&nPuwT#{bjS{qJ?O=QKbE(4PQDZMX>Yt>-KGp7H z<6ATeN*jtfkC~6W2nU;p@>t2y&2-NS{w)~U8V?SW;>50xJ=DHX`2(Cp>WNBcAm3=u z$9wlG&t)L)1&q#~lOQGkJw(p^>;%lo5q6TDLQWO&^VS4AOlAb}bsm_1z$WAl5KGH- z=?y6M_5k*A24g!X*q@x>*{JZP5)I%4Q#Cuox``8zJVNJljLwH4eOEj`6CO`4u4A1~Is%t7bZZQC zPPeN_y=Ix`S9rBWsoXF#HARv06s|Mo!}C`F%IhP#Oq!g#IqhKVB6k~=l_D7)iO*ej zs|`OuO5En&qqVBMFIn+wEkriEbq*S2s<3NUgt{K*3shlI=C2Wi1!^O$RTJd z6eR);JCuw6Bsi0hk3=d`861)3_YqxIPNWukq3k0)@|BAq+!&<$?9gGmh zVzXJ3eminZ0qzKKGl=^@vOPktd5yidLO-Qx&eGSR_->)$e?%XCM#X1T$dKgE0g2({q!Xbi zS8#(B>=KN)ohM-H6mub?kohai8XTWUde`d2U?h2F4OGga`Bu;3+2|7u|1PWoa++andSe45QrIfqq z%NC%bkPYMMKRCFgOo^bsrsD6Y*rehd6{k=@<@jz8Z0z`2B2!+&Rc@tOYx0=Ve@mTD zP-mT0>oJo_{<=&b=wcv_%lEJw1%8rgy1S&cVre@OxP&t3hks^4S_l98LrH7Jzl_3A uY%9)wpwv$5d3Zzy@+8Rc>BDOLvvqsW?z8iFGWG?#WDnUk>RZT;Kl?x1Fg-Z{ literal 0 HcmV?d00001 diff --git a/lib/checks/__pycache__/detect-changes.cpython-310.pyc b/lib/checks/__pycache__/detect-changes.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..89c86010b6f042edb3abb57013bcbbd2161f57d6 GIT binary patch literal 1836 zcmZux-ER{|5Z~SV@cA==q!6{Jx*}A`YHiwwwh{_a1ED}glZb>?SVE`s?Z&=vzH@fh zF}BW9EAxt~KJ@`bvLE?}`mnEk>c47f^X1#8UN`Z z^taxe|BA5r9!PLt6txh=lq9%?%}iRv%vQ@Xv)!^8Ido?32fV<%IbMubky@=1wW;$Q zwaV0`1(++WO1-Cqf5$3Qgb^ishU&%fv(grmERw<>cH^k)AH)e0e$)-q4x@gX$suE@ zFT2d&edxDC!Td0#{=GY!Tg}oQgKKwVd2i76HzOHmsqnLb><^^4;g7;z5}exk(oeHA zI4^0I{(%>x?eR)DWFqUaMxV#WVKi!vdPzj|+39D)>;c^d5-ng79pV`TW{xK$C;SU( z<%E(c`F(X_<<`v3(Mv?F90Rvc>^a8h7YMBlV`{+x8J17w=&&bF%F zy}y6&(XC+T(f;nEeTBo<_@=_8x}!)YRDq?(G0#$Eb(mCk)@P}5hCG(6ZYw(tdrUdU zVKQKZ%HVTs*P#Ot7yE|gQqmtlPn4ou>p&>O5@t? z(~>sPx-llc|G%yYZEX$MZ`!qjc1@zE*3VETN5o_RZ$pfWk2imM5d3ie!5#hyD8KJs zalOM*_N>o0>e`;JVe;!GG`{?ve{Z!^<$QBc%kQ^~arON9&GE{I9pz^Ig7QM%>4#h} zRomp9L64wwYGkTpl=NPxk{Go6Jd2nR%Ho4mc^WSP07O-GAMX5k=k|W^ zaA#*GNBx3Ua91t!ZVc>rXy3H$^u2@B-xPhdO^A7pIq7I zuP$d|kVsWM?U1HVh()S>FvCC7Ot*{$GfB5~^e%%;=$u#ZGWM`dE@BrG?Ebsvdc@N* zkX!^>Bm5gsQ(h40dqGgIsX`FYED8dy4RBYp&$W&vMnc*BP&(46bbCIUYFf;cLVup5+z1k4xBo^B01B* literal 0 HcmV?d00001 diff --git a/lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc b/lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..53295211e3ced8b7d90b3ec0a65ca9afa2498c2f GIT binary patch literal 2220 zcmb7F-ER{|5Z}E!pD$gK ze6pXyW235ls8pdyw)(`sw6A^YKhOuHmd;)Xp@3BN_I7q>c6N4l=eM)*=%|a}`>pgc zxS2)hcg?*2jR12Ks`v#6MP)=WH9}m*X={{?v^C3S+FE6c8I8=q{)lFoGsH)wJVMQ~ z%dAFjkRPJ>66lT&#;7&Kc~l-}Xe{fD6Iu6RxWh!;WW^Q_p83^osoM-o z?sh#81b+6s#j27~vKw>qkX1w=8F|p6J~SX%4MY{Ji}R$?mZa%-NyI>zJ(t`koIMLz zr{w+tCxzCM=}#=AoV9gtdHw$SW)*9#x1a8tP?w>KJP?X|=p}6K(A+`=b1litKfZjq znOO6WFE(GHS6E@e^@NnXo)3e{g=(EupNb0|IEP{sN5!rW`)YNSb0hSd73wceYU2iw zJmL^(^pOYJi_`-PIz0p{2d`rQ9nUi@szK`W-V~kE57_gRg8$HSQGcrdfdzqK$&^Q^} zyCcUN6N5=QHN<_pXY*g_G@Yp#{cJib*U$8_gDHAS&!e-4<`84F=clsMx}WyubWcwA zZ2H;0@#8ei$qZ)bnZc=Ec8KYj(@2j$go|WB*@xY0KQAHia|h(QJkr1hnXZNTwS;J3 z6TcDz-gH9F-@uf<{A?Pta6lzDi2yRh@-?U7AwABMb37&le4#=OKO1xaPATr z6Sx$+#IN~5BxJ!*4rhDqfHSJBItyFM1XX2er6gA@Mm}7VI10OoT`JvOS-M@Y)L7MT z$u?&mIMix`vIOv}Oo~2bRy*`xu+nhTINgFOW`NWn+)wO2?qP29@K44b+~E^sy)u+h zx~TB(i6{k|a&Lrzkkz=^TKrs_(spN1GS`HSlx~D^)eprYbdHriazZ0Gbz<(=2}e62 z(NpsSN8mh1^dWn9?%iME8sQ%}!}UH0)YOYPlw3)Z$N$Ya*I^+`C#S?9L`{EpZ~N|p zTi(|8%7(Y~V0CqA^HFm4+&Qv#9P{O)aHn_(qMDmr`|o9UK9PcUuDgj@Bs;J1jzUSi zNEQ~>V#%&AERfY$NS)n?sK@P)5*_-ZMA{{vG*vor?=2eBocI=0#HTdSnDoPUxlv(X zVYe`=9G}-(J{L@lE%92r$s)O-S+4Rh&4bZYrrcCX^Fq0zU1{;S%7joRZ%4|}NcRBR zD!;M0{{71Gwzs*yzO8H_Dd1COgoJAW^IBmb!ysZpWgzWGQrTS=hH*!kqAOB$#^5N}aPg6YWr{(W8@~Ui$DT z2QICrb1|O=$f&$%H=921rlTs8`t$+5l%-LvK_;#O$>Ry^V9S`oHa4*R*PJ~DoQ0_GTZcKLZNDQb!9wILeSIE*>}T1$^T x&=$dfP6Y1}{ffZr1P^sPWQ+Ro5odr{$jLacV+Vg}SjL<=gB{cP%Ei{3e*o$%PP703 literal 0 HcmV?d00001 diff --git a/lib/checks/check-links.py b/lib/checks/check-links.py new file mode 100644 index 0000000..293fa24 --- /dev/null +++ b/lib/checks/check-links.py @@ -0,0 +1,211 @@ +""" +Validates URLs and metadata for added/modified services in a PR. +Reads the diff JSON produced by check-yaml-diff.py. +All checks are warnings only -- this script never fails (exit 0). +""" + +import argparse +import json +import os +import sys + +import requests +import yaml + +# Paths (relative to project root) +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml") + +# Exit codes +EXIT_PASS = 0 +EXIT_RUNTIME_ERROR = 2 + +# ANSI color helpers +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) +green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) +yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) + +TIMEOUT = 10 +USER_AGENT = "awesome-privacy-ci/1.0" +DESC_MIN_LEN = 50 +DESC_MAX_LEN = 250 + + +def check_url(url, label): + """Check if a URL is reachable. Returns (ok, message).""" + try: + resp = requests.head( + url, + timeout=TIMEOUT, + allow_redirects=True, + headers={"User-Agent": USER_AGENT}, + ) + if resp.status_code >= 400: + # Retry with GET -- some servers reject HEAD + resp = requests.get( + url, + timeout=TIMEOUT, + allow_redirects=True, + headers={"User-Agent": USER_AGENT}, + stream=True, + ) + resp.close() + if resp.status_code >= 400: + return False, f"{label}: HTTP {resp.status_code} for {url}" + return True, None + except requests.RequestException as e: + return False, f"{label}: Connection error for {url} ({type(e).__name__})" + + +def check_service(service_data, service_name, category, section): + """Run all checks on a single service. Returns list of warning strings.""" + warnings = [] + name_prefix = f"{category} > {section} > {service_name}" + + # Check url + url = service_data.get("url") + if url: + ok, msg = check_url(url, "url") + if not ok: + warnings.append(f"{name_prefix}: {msg}") + else: + warnings.append(f"{name_prefix}: missing required field 'url'") + + # Check icon + icon = service_data.get("icon") + if icon: + ok, msg = check_url(icon, "icon") + if not ok: + warnings.append(f"{name_prefix}: {msg}") + else: + warnings.append(f"{name_prefix}: missing 'icon' field (recommended by contributing guide)") + + # Check iosApp + ios_app = service_data.get("iosApp") + if ios_app: + ok, msg = check_url(ios_app, "iosApp") + if not ok: + warnings.append(f"{name_prefix}: {msg}") + + # Check github + github = service_data.get("github") + if github: + github_url = f"https://github.com/{github}" + ok, msg = check_url(github_url, "github") + if not ok: + warnings.append(f"{name_prefix}: {msg}") + + # Check description length + desc = service_data.get("description", "") + desc_stripped = desc.strip() + desc_len = len(desc_stripped) + if desc_len < DESC_MIN_LEN: + warnings.append(f"{name_prefix}: description too short ({desc_len} chars, minimum {DESC_MIN_LEN})") + elif desc_len > DESC_MAX_LEN: + warnings.append(f"{name_prefix}: description too long ({desc_len} chars, maximum {DESC_MAX_LEN})") + + return warnings + + +def find_service_in_head(category, section, service_name): + """Look up a service in the head YAML by category/section/service name.""" + try: + with open(DATA_PATH, "r") as f: + data = yaml.safe_load(f) + for cat in data.get("categories", []): + if cat.get("name") == category: + for sec in cat.get("sections", []): + if sec.get("name") == section: + for svc in sec.get("services", []): + if svc.get("name") == service_name: + return svc + except Exception: + pass + return None + + +def write_step_summary(all_warnings, services_checked): + """Write a Markdown summary to $GITHUB_STEP_SUMMARY.""" + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + + lines = ["## Link Validation\n"] + + if not services_checked: + lines.append("No services to check.\n") + elif not all_warnings: + lines.append(f"All checks passed for {services_checked} service(s).\n") + else: + lines.append(f"Checked {services_checked} service(s), found {len(all_warnings)} warning(s):\n") + lines.append("| Warning |") + lines.append("|---------|") + for w in all_warnings: + escaped = w.replace("|", "\\|") + lines.append(f"| {escaped} |") + lines.append("") + lines.append("> **Note:** Link warnings are informational only and do not fail the check. " + "URLs may be temporarily down or block automated requests.\n") + + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + parser = argparse.ArgumentParser(description="Validate links for added/modified services") + parser.add_argument("--diff-json", required=True, help="Path to the diff JSON file") + args = parser.parse_args() + + # Load diff + try: + with open(args.diff_json, "r") as f: + diff = json.load(f) + except (FileNotFoundError, json.JSONDecodeError) as e: + print(red(f"Failed to load diff JSON: {e}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) + + all_warnings = [] + services_checked = 0 + + # Check added services + for svc in diff.get("services", {}).get("added", []): + services_checked += 1 + warnings = check_service( + svc.get("fields", {}), + svc["service"], + svc["category"], + svc["section"], + ) + all_warnings.extend(warnings) + + # Check modified services -- only if they have URL-related field changes + for svc in diff.get("services", {}).get("modified", []): + url_fields = {"url", "icon", "iosApp", "github", "description"} + changed = set(svc.get("changed_fields", [])) + if changed & url_fields: + services_checked += 1 + head_svc = find_service_in_head(svc["category"], svc["section"], svc["service"]) + if head_svc: + warnings = check_service( + head_svc, + svc["service"], + svc["category"], + svc["section"], + ) + all_warnings.extend(warnings) + + # Print results + if all_warnings: + print(yellow(f"Link validation: {len(all_warnings)} warning(s)"), file=sys.stderr) + for w in all_warnings: + print(f" {yellow('WARNING')} {w}", file=sys.stderr) + else: + print(green(f"Link validation passed. {services_checked} service(s) checked.")) + + write_step_summary(all_warnings, services_checked) + sys.exit(EXIT_PASS) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-readme-edits.py b/lib/checks/check-readme-edits.py new file mode 100644 index 0000000..aa733b5 --- /dev/null +++ b/lib/checks/check-readme-edits.py @@ -0,0 +1,123 @@ +""" +Fails if the PR directly edits the auto-generated section of the README. +The generated section is between and . +""" + +import argparse +import os +import re +import subprocess +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +README_PATH = ".github/README.md" +README_ABS = os.path.join(PROJECT_ROOT, README_PATH) + +# Exit codes +EXIT_PASS = 0 +EXIT_FAIL = 1 +EXIT_RUNTIME_ERROR = 2 + +# ANSI color helpers +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) +green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) + + +def get_changed_files(base_ref): + result = subprocess.run( + ["git", "diff", "--name-only", f"{base_ref}..HEAD"], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + return result.stdout.strip().splitlines() + + +def get_marker_lines(): + """Find the line numbers of the start/end markers in the README.""" + try: + with open(README_ABS, "r") as f: + lines = f.readlines() + except FileNotFoundError: + return None, None + + start_line = None + end_line = None + for i, line in enumerate(lines, start=1): + if "" in line: + start_line = i + if "" in line: + end_line = i + + return start_line, end_line + + +def get_changed_line_numbers(base_ref): + """Parse git diff hunk headers to find which lines were changed in the README.""" + result = subprocess.run( + ["git", "diff", "-U0", f"{base_ref}..HEAD", "--", README_PATH], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + + changed_lines = [] + for line in result.stdout.splitlines(): + # Match hunk headers like @@ -10,5 +12,7 @@ + match = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", line) + if match: + start = int(match.group(1)) + count = int(match.group(2)) if match.group(2) else 1 + for n in range(start, start + count): + changed_lines.append(n) + + return changed_lines + + +def write_step_summary(): + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + + lines = [ + "## Direct README Edit Detected\n", + "This PR directly modifies the auto-generated section of `.github/README.md` " + "(between `` and ``).\n", + "**Please edit `awesome-privacy.yml` instead.** The README is regenerated automatically from that file.\n", + ] + + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + parser = argparse.ArgumentParser(description="Check for direct README edits to generated section") + parser.add_argument("--base-ref", required=True, help="Base git ref to diff against") + args = parser.parse_args() + + # Skip if README wasn't changed + changed_files = get_changed_files(args.base_ref) + if README_PATH not in changed_files: + print(green("README not modified, skipping.")) + sys.exit(EXIT_PASS) + + # Find marker lines + start_line, end_line = get_marker_lines() + if start_line is None or end_line is None: + print("Could not find generated-section markers in README, skipping check.") + sys.exit(EXIT_PASS) + + # Check if any changed lines fall within the generated section + changed_lines = get_changed_line_numbers(args.base_ref) + for line_num in changed_lines: + if start_line <= line_num <= end_line: + print(red("Direct edits to the generated section of the README are not allowed."), file=sys.stderr) + print(red("Edit awesome-privacy.yml instead and the README will be regenerated."), file=sys.stderr) + write_step_summary() + sys.exit(EXIT_FAIL) + + print(green("README changes are outside the generated section, OK.")) + sys.exit(EXIT_PASS) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-template.py b/lib/checks/check-template.py new file mode 100644 index 0000000..b53671d --- /dev/null +++ b/lib/checks/check-template.py @@ -0,0 +1,206 @@ +""" +Checks PR body against the pull request template. +Reads the PR body from the PR_BODY environment variable (avoids shell injection). +Exits with code 1 for severe violations (empty body, missing required sections). +""" + +import os +import re +import sys + +# Exit codes +EXIT_PASS = 0 +EXIT_FAIL = 1 +EXIT_RUNTIME_ERROR = 2 + +# Warnings that should cause a hard failure +CRITICAL_WARNINGS = { + "PR body is empty or not provided", + "Type section is missing", + "Type section is empty", + "Changes section is missing", + "Changes section is empty", + "Checklist section is missing", + "Checklist section does not contain any checkbox items", +} + +# ANSI color helpers +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) +green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) +yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) + +# Valid PR types from the template +VALID_TYPES = {"Addition", "Amendment", "Removal", "Spelling or Grammar", "Website Update", "Misc"} + +# Raw template text that indicates an unfilled section +RAW_TYPE_LINE = "Addition / Amendment / Removal / Spelling or Grammar / Website Update / Misc" + + +def strip_html_comments(text): + """Remove comments from text.""" + return re.sub(r"", "", text, flags=re.DOTALL).strip() + + +def extract_section(body, header): + """Extract content between a ### header and the next --- or ### header.""" + pattern = rf"###\s*{re.escape(header)}\s*\n(.*?)(?=\n---|\n###|\Z)" + match = re.search(pattern, body, re.DOTALL) + if match: + return match.group(1) + return None + + +def check_type_section(content): + """Check the Type section. Returns list of warning strings.""" + warnings = [] + + if content is None: + warnings.append("Type section is missing") + return warnings + + cleaned = strip_html_comments(content).strip() + + if not cleaned: + warnings.append("Type section is empty") + return warnings + + # Check for raw template text (unchanged) + if RAW_TYPE_LINE in cleaned: + warnings.append("Type section appears unchanged from the template -- please select one type") + return warnings + + # Check how many valid types are present + found_types = [t for t in VALID_TYPES if t in cleaned] + + if len(found_types) == 0: + warnings.append(f"Type section does not contain a recognized type. Expected one of: {', '.join(sorted(VALID_TYPES))}") + elif len(found_types) > 1: + warnings.append(f"Type section contains multiple types ({', '.join(found_types)}) -- please select only one") + + return warnings + + +def check_text_section(content, section_name): + """Check a text section (Changes, Supporting Material, Affiliation). Returns list of warnings.""" + warnings = [] + + if content is None: + warnings.append(f"{section_name} section is missing") + return warnings + + cleaned = strip_html_comments(content).strip() + + if not cleaned: + warnings.append(f"{section_name} section is empty") + + return warnings + + +def check_checklist(content): + """Check the Checklist section. Returns list of warnings.""" + warnings = [] + + if content is None: + warnings.append("Checklist section is missing") + return warnings + + checked = re.findall(r"- \[x\]", content, re.IGNORECASE) + unchecked = re.findall(r"- \[ \]", content) + + total = len(checked) + len(unchecked) + + if total == 0: + warnings.append("Checklist section does not contain any checkbox items") + return warnings + + if unchecked: + warnings.append(f"Checklist has {len(unchecked)} unchecked item(s) out of {total}") + + return warnings + + +def has_critical_warnings(warnings): + """Return True if any warning is a critical (hard-fail) violation.""" + return any(w in CRITICAL_WARNINGS for w in warnings) + + +def write_step_summary(all_warnings): + """Write a Markdown summary to $GITHUB_STEP_SUMMARY.""" + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + + lines = ["## PR Template Check\n"] + + if not all_warnings: + lines.append("All template checks passed.\n") + else: + critical = has_critical_warnings(all_warnings) + lines.append(f"Found {len(all_warnings)} warning(s):\n") + for w in all_warnings: + lines.append(f"- {w}") + lines.append("") + if critical: + lines.append("> **Error:** One or more required sections are missing or empty. " + "Please fill out the PR template before this check can pass.\n") + else: + lines.append("> **Note:** Template warnings are informational and do not fail the check. " + "Reviewers will verify compliance.\n") + + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + pr_body = os.environ.get("PR_BODY") + + if pr_body is None or pr_body.strip() == "": + all_warnings = [ + "PR body is empty or not provided", + "Type section is missing", + "Changes section is missing", + "Supporting Material section is missing", + "Affiliation section is missing", + "Checklist section is missing", + ] + print(yellow(f"PR template check: {len(all_warnings)} warning(s)"), file=sys.stderr) + for w in all_warnings: + print(f" {yellow('WARNING')} {w}", file=sys.stderr) + write_step_summary(all_warnings) + sys.exit(EXIT_FAIL) + + all_warnings = [] + + # Check each section + type_content = extract_section(pr_body, "Type") + all_warnings.extend(check_type_section(type_content)) + + changes_content = extract_section(pr_body, "Changes") + all_warnings.extend(check_text_section(changes_content, "Changes")) + + supporting_content = extract_section(pr_body, "Supporting Material") + all_warnings.extend(check_text_section(supporting_content, "Supporting Material")) + + affiliation_content = extract_section(pr_body, "Affiliation") + all_warnings.extend(check_text_section(affiliation_content, "Affiliation")) + + checklist_content = extract_section(pr_body, "Checklist") + all_warnings.extend(check_checklist(checklist_content)) + + # Print results + if all_warnings: + print(yellow(f"PR template check: {len(all_warnings)} warning(s)"), file=sys.stderr) + for w in all_warnings: + print(f" {yellow('WARNING')} {w}", file=sys.stderr) + else: + print(green("PR template check passed.")) + + write_step_summary(all_warnings) + if has_critical_warnings(all_warnings): + sys.exit(EXIT_FAIL) + sys.exit(EXIT_PASS) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-yaml-diff.py b/lib/checks/check-yaml-diff.py new file mode 100644 index 0000000..2931e68 --- /dev/null +++ b/lib/checks/check-yaml-diff.py @@ -0,0 +1,342 @@ +""" +Analyzes the diff between base and head versions of awesome-privacy.yml. +Enforces the single-entry rule: only one service addition/amendment/removal per PR. +Outputs a JSON diff to /tmp/pr-diff.json and writes a step summary. +""" + +import argparse +import json +import os +import subprocess +import sys + +import yaml + +# Paths (relative to project root) +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml") +DIFF_OUTPUT_PATH = "/tmp/pr-diff.json" + +# Exit codes +EXIT_PASS = 0 +EXIT_RULE_VIOLATION = 1 +EXIT_RUNTIME_ERROR = 2 + +# ANSI color helpers +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) +green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) +yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) +dim = (lambda s: f"\033[2m{s}\033[0m") if _use_color else (lambda s: s) + + +def load_base_yaml(base_ref): + """Load the YAML from the base ref using git show.""" + try: + result = subprocess.run( + ["git", "show", f"{base_ref}:awesome-privacy.yml"], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + return yaml.safe_load(result.stdout) + except subprocess.CalledProcessError: + # File doesn't exist in base (completely new file) + print(yellow("Warning: awesome-privacy.yml not found in base ref, treating as empty"), file=sys.stderr) + return {"categories": []} + except yaml.YAMLError as e: + print(red(f"Failed to parse base YAML: {e}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) + + +def load_head_yaml(): + """Load the YAML from the current working tree.""" + try: + with open(DATA_PATH, "r") as f: + return yaml.safe_load(f) + except FileNotFoundError: + print(red(f"File not found: {DATA_PATH}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) + except yaml.YAMLError as e: + print(red(f"Failed to parse head YAML: {e}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) + + +def build_service_index(data): + """Build a dict keyed by (category, section, service_name) -> service dict.""" + index = {} + for cat in data.get("categories", []): + cat_name = cat.get("name", "") + for sec in cat.get("sections", []): + sec_name = sec.get("name", "") + for svc in sec.get("services", []): + svc_name = svc.get("name", "") + key = (cat_name, sec_name, svc_name) + index[key] = svc + return index + + +def build_section_index(data): + """Build a dict keyed by (category, section) -> section metadata (excluding services).""" + index = {} + for cat in data.get("categories", []): + cat_name = cat.get("name", "") + for sec in cat.get("sections", []): + sec_name = sec.get("name", "") + key = (cat_name, sec_name) + meta = {k: v for k, v in sec.items() if k != "services"} + index[key] = meta + return index + + +def build_category_index(data): + """Build a dict keyed by category_name -> category metadata (excluding sections).""" + index = {} + for cat in data.get("categories", []): + cat_name = cat.get("name", "") + meta = {k: v for k, v in cat.items() if k != "sections"} + index[cat_name] = meta + return index + + +def diff_services(base_data, head_data): + """Find added, removed, and modified services.""" + base_idx = build_service_index(base_data) + head_idx = build_service_index(head_data) + + base_keys = set(base_idx.keys()) + head_keys = set(head_idx.keys()) + + added = [] + for key in sorted(head_keys - base_keys): + added.append({ + "category": key[0], + "section": key[1], + "service": key[2], + "fields": head_idx[key], + }) + + removed = [] + for key in sorted(base_keys - head_keys): + removed.append({ + "category": key[0], + "section": key[1], + "service": key[2], + }) + + modified = [] + for key in sorted(base_keys & head_keys): + base_svc = base_idx[key] + head_svc = head_idx[key] + if base_svc != head_svc: + changed_fields = [] + all_fields = set(base_svc.keys()) | set(head_svc.keys()) + for field in sorted(all_fields): + old_val = base_svc.get(field) + new_val = head_svc.get(field) + if old_val != new_val: + changed_fields.append(field) + modified.append({ + "category": key[0], + "section": key[1], + "service": key[2], + "changed_fields": changed_fields, + }) + + return added, removed, modified + + +def diff_sections(base_data, head_data): + """Find section-level metadata changes (intro, wordOfWarning, etc.).""" + base_idx = build_section_index(base_data) + head_idx = build_section_index(head_data) + + base_keys = set(base_idx.keys()) + head_keys = set(head_idx.keys()) + + changes = [] + + # New sections + for key in sorted(head_keys - base_keys): + changes.append({ + "category": key[0], + "section": key[1], + "change_type": "added_section", + }) + + # Removed sections + for key in sorted(base_keys - head_keys): + changes.append({ + "category": key[0], + "section": key[1], + "change_type": "removed_section", + }) + + # Modified section metadata + for key in sorted(base_keys & head_keys): + base_meta = base_idx[key] + head_meta = head_idx[key] + if base_meta != head_meta: + changed_fields = [] + all_fields = set(base_meta.keys()) | set(head_meta.keys()) + for field in sorted(all_fields): + if base_meta.get(field) != head_meta.get(field): + changed_fields.append(field) + changes.append({ + "category": key[0], + "section": key[1], + "change_type": "modified_section_metadata", + "changed_fields": changed_fields, + }) + + return changes + + +def diff_categories(base_data, head_data): + """Find structural category changes.""" + base_idx = build_category_index(base_data) + head_idx = build_category_index(head_data) + + base_keys = set(base_idx.keys()) + head_keys = set(head_idx.keys()) + + changes = [] + + for name in sorted(head_keys - base_keys): + changes.append({"category": name, "change_type": "added_category"}) + + for name in sorted(base_keys - head_keys): + changes.append({"category": name, "change_type": "removed_category"}) + + return changes + + +def write_github_output(name, value): + """Write a value to $GITHUB_OUTPUT.""" + output_file = os.environ.get("GITHUB_OUTPUT") + if output_file: + with open(output_file, "a") as f: + f.write(f"{name}={value}\n") + + +def write_step_summary(diff_result): + """Write a Markdown summary to $GITHUB_STEP_SUMMARY.""" + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + + lines = ["## YAML Diff Analysis\n"] + + added = diff_result["services"]["added"] + removed = diff_result["services"]["removed"] + modified = diff_result["services"]["modified"] + section_changes = diff_result["sections"] + category_changes = diff_result["categories"] + + if not added and not removed and not modified and not section_changes and not category_changes: + lines.append("No changes detected in `awesome-privacy.yml`.\n") + else: + lines.append("| Type | Category | Section | Service | Details |") + lines.append("|------|----------|---------|---------|---------|") + + for svc in added: + lines.append(f"| Added | {svc['category']} | {svc['section']} | {svc['service']} | New service |") + + for svc in removed: + lines.append(f"| Removed | {svc['category']} | {svc['section']} | {svc['service']} | Service removed |") + + for svc in modified: + fields = ", ".join(svc["changed_fields"]) + lines.append(f"| Modified | {svc['category']} | {svc['section']} | {svc['service']} | Changed: {fields} |") + + for change in section_changes: + detail = change["change_type"].replace("_", " ").title() + fields = ", ".join(change.get("changed_fields", [])) + if fields: + detail += f" ({fields})" + lines.append(f"| Section | {change['category']} | {change['section']} | - | {detail} |") + + for change in category_changes: + detail = change["change_type"].replace("_", " ").title() + lines.append(f"| Category | {change['category']} | - | - | {detail} |") + + lines.append("") + + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + parser = argparse.ArgumentParser(description="Analyze YAML diff for PR checks") + parser.add_argument("--base-ref", required=True, help="Base git ref (SHA or branch) to diff against") + args = parser.parse_args() + + # Load both versions + base_data = load_base_yaml(args.base_ref) + head_data = load_head_yaml() + + # Compute diffs + added, removed, modified = diff_services(base_data, head_data) + section_changes = diff_sections(base_data, head_data) + category_changes = diff_categories(base_data, head_data) + + # Build result + diff_result = { + "services": { + "added": added, + "removed": removed, + "modified": modified, + }, + "sections": section_changes, + "categories": category_changes, + } + + # Write diff JSON + with open(DIFF_OUTPUT_PATH, "w") as f: + json.dump(diff_result, f, indent=2) + print(f"Diff written to {DIFF_OUTPUT_PATH}") + + # Determine if there are service-level changes + has_service_changes = bool(added or removed or modified) + write_github_output("has_service_changes", str(has_service_changes).lower()) + + # Write step summary + write_step_summary(diff_result) + + # Enforce single-entry rule + service_change_count = len(added) + len(removed) + len(modified) + + if service_change_count > 1: + print(red("Single-entry rule violation: PRs must contain only one service change."), file=sys.stderr) + print(red(f"Found {service_change_count} service-level changes:"), file=sys.stderr) + for svc in added: + print(f" + Added: {svc['category']} > {svc['section']} > {svc['service']}", file=sys.stderr) + for svc in removed: + print(f" - Removed: {svc['category']} > {svc['section']} > {svc['service']}", file=sys.stderr) + for svc in modified: + fields = ", ".join(svc["changed_fields"]) + print(f" ~ Modified: {svc['category']} > {svc['section']} > {svc['service']} ({fields})", file=sys.stderr) + sys.exit(EXIT_RULE_VIOLATION) + + # If no service changes, check section-level changes + if service_change_count == 0 and len(section_changes) > 1: + print(red("Single-entry rule violation: PRs must contain only one section-level change."), file=sys.stderr) + print(red(f"Found {len(section_changes)} section-level changes:"), file=sys.stderr) + for change in section_changes: + detail = change["change_type"].replace("_", " ") + fields = change.get("changed_fields", []) + extra = f" ({', '.join(fields)})" if fields else "" + print(f" ~ {change['category']} > {change['section']}: {detail}{extra}", file=sys.stderr) + sys.exit(EXIT_RULE_VIOLATION) + + # Summary + total = service_change_count + len(section_changes) + len(category_changes) + if total == 0: + print(green("No changes detected in awesome-privacy.yml")) + else: + print(green(f"Single-entry rule passed. {service_change_count} service change(s), " + f"{len(section_changes)} section change(s), {len(category_changes)} category change(s).")) + sys.exit(EXIT_PASS) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/detect-changes.py b/lib/checks/detect-changes.py new file mode 100644 index 0000000..9b87e78 --- /dev/null +++ b/lib/checks/detect-changes.py @@ -0,0 +1,50 @@ +""" +Detects which files changed between the PR base and HEAD. +Sets GitHub Actions outputs: yaml_changed, non_yaml_changed. +""" + +import argparse +import os +import subprocess +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +YAML_FILE = "awesome-privacy.yml" + + +def write_github_output(name, value): + output_file = os.environ.get("GITHUB_OUTPUT") + if output_file: + with open(output_file, "a") as f: + f.write(f"{name}={value}\n") + + +def main(): + parser = argparse.ArgumentParser(description="Detect changed files in a PR") + parser.add_argument("--base-ref", required=True, help="Base git ref to diff against") + args = parser.parse_args() + + result = subprocess.run( + ["git", "diff", "--name-only", f"{args.base_ref}..HEAD"], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + + changed_files = [f for f in result.stdout.strip().splitlines() if f] + + print("Changed files:") + for f in changed_files: + print(f" {f}") + + yaml_changed = YAML_FILE in changed_files + non_yaml_changed = any(f != YAML_FILE for f in changed_files) + + write_github_output("yaml_changed", str(yaml_changed).lower()) + write_github_output("non_yaml_changed", str(non_yaml_changed).lower()) + + print(f"yaml_changed={yaml_changed}, non_yaml_changed={non_yaml_changed}") + + +if __name__ == "__main__": + main() diff --git a/lib/checks/warn-non-yaml.py b/lib/checks/warn-non-yaml.py new file mode 100644 index 0000000..e244dbc --- /dev/null +++ b/lib/checks/warn-non-yaml.py @@ -0,0 +1,58 @@ +""" +Warns when a PR modifies files other than awesome-privacy.yml. +This is expected for Website Update or Misc PRs, but may need extra review. +""" + +import argparse +import os +import subprocess +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +YAML_FILE = "awesome-privacy.yml" + +# ANSI color helpers +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) + + +def main(): + parser = argparse.ArgumentParser(description="Warn about non-YAML file changes") + parser.add_argument("--base-ref", required=True, help="Base git ref to diff against") + args = parser.parse_args() + + result = subprocess.run( + ["git", "diff", "--name-only", f"{args.base_ref}..HEAD"], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + + changed_files = [f for f in result.stdout.strip().splitlines() if f] + non_yaml = [f for f in changed_files if f != YAML_FILE] + + if not non_yaml: + return + + print(yellow("This PR modifies files other than awesome-privacy.yml:"), file=sys.stderr) + for f in non_yaml: + print(f" {f}", file=sys.stderr) + + # Write step summary + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_file: + lines = [ + "## Non-YAML Changes Warning\n", + "This PR modifies files other than `awesome-privacy.yml`:\n", + ] + for f in non_yaml: + lines.append(f"- `{f}`") + lines.append("") + lines.append("> **Note:** Most PRs should only modify `awesome-privacy.yml`. " + "Non-YAML changes may require additional review.\n") + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +if __name__ == "__main__": + main() diff --git a/lib/requirements.txt b/lib/requirements.txt index 3e298b1..f59b908 100644 --- a/lib/requirements.txt +++ b/lib/requirements.txt @@ -1,2 +1,3 @@ PyYAML==6.0.1 jsonschema==4.23.0 +requests==2.32.3 diff --git a/lib/schema.json b/lib/schema.json index d77f6a3..2ca04d2 100644 --- a/lib/schema.json +++ b/lib/schema.json @@ -7,38 +7,64 @@ "items": { "type": "object", "properties": { - "name": { "type": "string" }, + "name": { "type": "string", "minLength": 1, "maxLength": 50 }, "sections": { "type": "array", "items": { "type": "object", "properties": { - "name": { "type": "string" }, + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, "services": { "type": "array", "items": { "type": "object", "properties": { - "name": { "type": "string" }, - "description": { "type": "string" }, - "url": { "type": "string" }, - "github": { "type": ["string", "null"] }, - "icon": { "type": ["string", "null"] }, - "followWith": { "type": ["string", "null"] }, + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "description": { "type": "string", "minLength": 10, "maxLength": 1500 }, + "url": { + "type": "string", + "anyOf": [ + { "pattern": "^https?://" }, + { "maxLength": 0 } + ] + }, + "github": { + "type": ["string", "null"], + "pattern": "^([a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+|https://github\\.com/.+)$" + }, + "icon": { + "type": ["string", "null"], + "pattern": "^https?://.+" + }, + "followWith": { "type": ["string", "null"], "minLength": 1, "maxLength": 100 }, "securityAudited": { "type": ["boolean", "null"] }, "openSource": { "type": ["boolean", "null"] }, "acceptsCrypto": { "type": ["boolean", "null"] }, - "tosdrId": { "type": ["number", "null"] }, - "iosApp": { "type": ["string", "null"] }, - "androidApp": { "type": ["string", "null"] }, - "discordInvite": { "type": ["string", "null"] }, - "subreddit": { "type": ["string", "null"] } + "tosdrId": { "type": ["integer", "null"], "minimum": 1 }, + "iosApp": { + "type": ["string", "null"], + "pattern": "^https://apps\\.apple\\.com/" + }, + "androidApp": { + "type": ["string", "null"], + "pattern": "^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)+$" + }, + "discordInvite": { + "type": ["string", "null"], + "pattern": "^(https://discord\\.gg/[a-zA-Z0-9]+|[a-zA-Z0-9]+|)$" + }, + "subreddit": { + "type": ["string", "null"], + "pattern": "^[a-zA-Z0-9_]+$", + "minLength": 1, + "maxLength": 50 + } }, "required": ["name", "description", "url"], "additionalProperties": false } }, - "intro": { "type": ["string", "null"] }, + "intro": { "type": ["string", "null"], "minLength": 1 }, "notableMentions": { "oneOf": [ { @@ -46,25 +72,26 @@ "items": { "type": "object", "properties": { - "name": { "type": "string" }, - "description": { "type": "string" }, - "url": { "type": "string" } + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "description": { "type": "string", "minLength": 1 }, + "url": { "type": "string", "pattern": "^https?://" } }, "required": ["name", "url"], "additionalProperties": false } }, - { "type": "string" }, + { "type": "string", "minLength": 1 }, { "type": "null" } ] }, - "furtherInfo": { "type": ["string", "null"] }, - "wordOfWarning": { "type": ["string", "null"] }, + "furtherInfo": { "type": ["string", "null"], "minLength": 1 }, + "wordOfWarning": { "type": ["string", "null"], "minLength": 1 }, "alternativeTo": { "oneOf": [ { "type": "array", - "items": { "type": "string" } + "items": { "type": "string", "minLength": 1, "maxLength": 100 }, + "minItems": 1 }, { "type": "null" } ]